Skip to content

Commit b4e217f

Browse files
committed
Fix a stored XSS in user-supplied URLs, found by the CI gate nobody had run
The branch is 600 commits ahead of main with no pull request, so I checked which workflows one would trigger. Six: rspec, rspec-system, audit-selftest, ruby_lint, brakeman, factory-bot-lint. The design suite covers the first four and I had been running them all session. I had never run the last two. Brakeman failed -- LinkToHref, weak confidence, on organizations/_details.html.erb -- and it was real. `Organization#url` validated with `URI::DEFAULT_PARSER.make_regexp` and no scheme argument, which accepts any scheme, so `javascript:alert(document.cookie)` was a valid organization URL. That field is rendered with `link_to`, so an organization admin sets it and anyone who views the page and clicks runs script in their own session. Weak confidence describes the static analysis, not the exposure. Restricting the scheme is not enough on its own, and that is the more interesting half. `BroadcastAnnouncement#link` already restricted it to `http https` and was still bypassable, because `format:` is unanchored and matches a substring: "javascript:alert(1) http://decoy.example.com" satisfied it. That field is the "More info" link on every user's dashboard -- the widest exposure of the three, and it had looked fixed. Found by testing the fix rather than trusting the pattern that was already there. Two layers: * HttpUrlValidatable -- anchored, http and https only -- on all three fields that take a URL from a user: Organization#url, BroadcastAnnouncement#link, AccountRequest#organization_website. * essentials_external_link and essentials_safe_href at the four render sites. Split in two because the dashboard link carries its own classes and accessible name, so it needs the href checked rather than the anchor built for it. Both, because the validation guards one write path and a row can arrive by CSV import, from the console, or from a database restored from before it existed. A dangerous URL renders as plain text rather than nothing: dropping it hides the problem from the only people who can fix it, and a bank looking at a nonsense URL is how it gets corrected. Checked before changing anything: 0 rows across all three fields held a non-http(s) value, so nothing was invalidated. Had there been any, the data migration would have had to come first. 43 new examples, and 21 of 36 watched failing against the old pattern. Brakeman 1 warning -> 0, exit 0. factory_bot:lint exit 0. Full suite 3,430 examples 0 failures. All 29 audits the selector named: clean, with dead-code at its documented 147. The lesson is not about URLs. Four of six gates were covered; the two that were not are where this was hiding, and it only surfaced because main bumped brakeman 8.0.5 -> 8.0.6 in the merge and a new version brings new checks. onboarding.md's post-merge routine has a step 9 now, naming both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6698b01 commit b4e217f

16 files changed

Lines changed: 302 additions & 10 deletions

app/helpers/essentials_ui_helper.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,33 @@ def essentials_action_button(label, path, method:, variant: :primary, size: :md,
306306
end
307307
end
308308

309+
# A user-supplied URL, rendered as a link only if it is safe to click.
310+
#
311+
# `HttpUrlValidatable` stops a `javascript:` or `data:` URL being *saved*, and this stops one
312+
# that was saved before that validation existed being *rendered* as a live link. Belt and
313+
# braces on purpose: the validation guards one write path, and a row can arrive by import, by
314+
# console, or from a database restored from before the fix.
315+
#
316+
# Falls back to plain text rather than dropping the value: the reader should still see what the
317+
# field contains, and a bank looking at a nonsense URL is how it gets corrected.
318+
# The URL if it is safe to put in an `href`, otherwise nil. Call sites that build their own
319+
# link -- the dashboard's "More info" carries its own classes and accessible name -- guard the
320+
# href with this and keep their markup.
321+
def essentials_safe_href(url)
322+
url if url.present? && url.match?(HttpUrlValidatable::HTTP_URL)
323+
end
324+
325+
def essentials_external_link(url, **html_attrs)
326+
return if url.blank?
327+
328+
href = essentials_safe_href(url)
329+
# Plain text rather than nothing: the reader should still see what the field holds, and a bank
330+
# looking at a nonsense URL is how it gets corrected.
331+
return tag.span(url, class: "text-slate-600") if href.nil?
332+
333+
link_to href, href, class: "link-brand", rel: "nofollow noopener", **html_attrs
334+
end
335+
309336
# --- Status pills ---------------------------------------------------------
310337
#
311338
# Never colour alone: every tone pairs its colour with a word, and callers may add an

app/models/account_request.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
# ndbn_member_id :bigint
1717
#
1818
class AccountRequest < ApplicationRecord
19+
include HttpUrlValidatable
20+
1921
has_paper_trail
2022
validates :name, presence: true
2123
validates :email, presence: true, uniqueness: true
2224
validates :request_details, presence: true, length: { minimum: 50 }
2325
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
24-
validates :organization_website, format: { with: URI::DEFAULT_PARSER.make_regexp, message: "should look like 'https://www.example.com'" }, allow_blank: true
26+
# Rendered as text rather than a link today, so this is consistency rather than a fix -- but a
27+
# field that looks like a URL tends to become one.
28+
validates_http_url :organization_website
2529

2630
validate :email_not_already_used_by_organization
2731
validate :email_not_already_used_by_user

app/models/broadcast_announcement.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
# user_id :bigint not null
1313
#
1414
class BroadcastAnnouncement < ApplicationRecord
15+
include HttpUrlValidatable
16+
1517
has_paper_trail
1618
belongs_to :user
1719
belongs_to :organization, optional: true
18-
validates :link, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
20+
# Scheme was already restricted here; the pattern was not anchored, so a valid URL appended to
21+
# a `javascript:` one satisfied it. Rendered as "More info" on every user's dashboard.
22+
validates_http_url :link
1923
validates :message, presence: true
2024

2125
def expired?
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
# A URL a user supplies and the app later renders as a link.
4+
#
5+
# **Two separate faults, both proven before this was written.**
6+
#
7+
# `URI::DEFAULT_PARSER.make_regexp` with no arguments accepts *any* scheme, so
8+
# `javascript:alert(document.cookie)` was a valid `Organization#url` -- and `organizations/show`
9+
# renders that field with `link_to`, which makes it a stored XSS: an organization admin sets it,
10+
# and anyone who views the page and clicks runs script in their own session.
11+
#
12+
# `format:` is not anchored. Even with the scheme restricted, as `BroadcastAnnouncement#link`
13+
# already had it, the regexp matches a *substring* -- so `"javascript:alert(1) http://decoy.com"`
14+
# satisfied it, and that field is rendered as the "More info" link on every user's dashboard.
15+
#
16+
# Brakeman found the first of these (LinkToHref, weak confidence) once its version was bumped by a
17+
# merge from main. The second was found by testing the fix.
18+
module HttpUrlValidatable
19+
extend ActiveSupport::Concern
20+
21+
# Anchored, and http(s) only. Everything else -- `javascript:`, `data:`, `file:` -- is rejected.
22+
HTTP_URL = /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/
23+
24+
class_methods do
25+
def validates_http_url(*attributes, message: "should look like 'https://www.example.com'")
26+
validates(*attributes, format: {with: HTTP_URL, message: message}, allow_blank: true)
27+
end
28+
end
29+
end

app/models/organization.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
#
4343

4444
class Organization < ApplicationRecord
45+
include HttpUrlValidatable
46+
4547
has_paper_trail
4648
resourcify
4749

@@ -51,7 +53,9 @@ class Organization < ApplicationRecord
5153
self.ignored_columns += ["short_name"]
5254

5355
validates :name, presence: true
54-
validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp, message: "it should look like 'http://www.example.com'" }, allow_blank: true
56+
# Rendered with `link_to` on the organization page, so the scheme has to be restricted --
57+
# see HttpUrlValidatable.
58+
validates_http_url :url, message: "it should look like 'http://www.example.com'"
5559
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
5660
validate :correct_logo_mime_type
5761
validate :some_request_type_enabled

app/views/admin/broadcast_announcements/_broadcast_announcement.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<tr id="<%= dom_id broadcast_announcement %>">
22
<td><%= broadcast_announcement.message %></td>
3-
<td><%= link_to broadcast_announcement.link, broadcast_announcement.link %></td>
3+
<td><%= essentials_external_link broadcast_announcement.link %></td>
44
<td><%= broadcast_announcement.user&.name || "N/A" %></td>
55
<td><%= broadcast_announcement.expiry %>
66
<% if broadcast_announcement.expired? %>

app/views/broadcast_announcements/_broadcast_announcement.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<tr id="<%= dom_id broadcast_announcement %>">
22
<td><%= broadcast_announcement.message %></td>
3-
<td><%= link_to broadcast_announcement.link, broadcast_announcement.link %></td>
3+
<td><%= essentials_external_link broadcast_announcement.link %></td>
44
<td><%= broadcast_announcement.user&.name || "N/A" %></td>
55
<td class="date text-left"><%= broadcast_announcement.expiry %>
66
<% if broadcast_announcement.expired? %>

app/views/dashboard/_announcements.html.erb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
<%= announcement.created_at.strftime(announcement.created_at.year == Date.current.year ? "%B %-d" : "%B %-d %Y") %>
1414
</p>
1515
<p class="mt-0.5 text-sm text-slate-700"><%= announcement.message %></p>
16-
<% if announcement.link.present? %>
16+
<%# The href is guarded rather than the whole link: this one carries its own classes
17+
and accessible name. An announcement link is the widest exposure of the three --
18+
every user's dashboard renders it. %>
19+
<% if (announcement_href = essentials_safe_href(announcement.link)) %>
1720
<%# The visible words stay short because the card is a compact grid cell, and the
1821
accessible name carries the context WCAG 2.4.4 wants -- "More info" on its own
1922
says nothing in a screen reader's list of links. The visible text is a prefix of
2023
the accessible name, which is what WCAG 2.5.3 Label in Name requires. %>
21-
<%= link_to "More info", announcement.link,
24+
<%= link_to "More info", announcement_href,
2225
aria: {label: "More info about the announcement of #{announcement.created_at.strftime("%B %-d")}"},
2326
class: "mt-2 inline-flex items-center gap-1 text-sm font-medium link-brand focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600" %>
2427
<% end %>

app/views/organizations/_details.html.erb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
<%= essentials_detail "Name", @organization.name %>
2727
<%= essentials_detail "NDBN membership ID", @organization.ndbn_member&.full_name %>
2828
<%= essentials_detail "URL" do %>
29-
<% if @organization.url.present? %>
30-
<%= link_to @organization.url, @organization.url, class: "link-brand" %>
31-
<% end %>
29+
<%# Guarded: a `javascript:` URL stored before HttpUrlValidatable existed would otherwise
30+
render as a live link here. This is the site Brakeman's LinkToHref found. %>
31+
<%= essentials_external_link @organization.url %>
3232
<% end %>
3333
<%= essentials_detail "Email" do %>
3434
<% if @organization.email.present? %>

design.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,30 @@ Target is **WCAG 2.2 AA**. These are the rules this app has actually had to enfo
471471
link that is its own block — a table cell, a list item, a card row — takes no *permanent*
472472
underline, because there is no adjacent body text to be confused with and a whole underlined
473473
column is noise; the hover and focus cue covers it.
474+
<a id="user-supplied-links"></a>
475+
- **A URL a user typed is never put straight into an `href`.** `essentials_external_link` for a
476+
link that is nothing but the URL, `essentials_safe_href` where the call site builds its own link
477+
and needs to keep its classes and accessible name. Both accept `http` and `https` and nothing
478+
else.
479+
480+
This is not theoretical. `Organization#url` validated with `URI::DEFAULT_PARSER.make_regexp` and
481+
no scheme argument, which accepts **any** scheme, so `javascript:alert(document.cookie)` was a
482+
valid organization URL — and the organization page rendered that field with `link_to`. An
483+
organization admin sets it; anyone who views the page and clicks runs script in their own
484+
session. Brakeman's `LinkToHref` found it, at weak confidence, once a merge from `main` bumped
485+
its version.
486+
487+
**And restricting the scheme is not enough on its own.** `format:` is not anchored, so the
488+
pattern matches a *substring*: `BroadcastAnnouncement#link` already restricted the scheme to
489+
`http https` and still accepted `"javascript:alert(1) http://decoy.example.com"` — a field
490+
rendered as the "More info" link on every user's dashboard. `HttpUrlValidatable::HTTP_URL` is
491+
anchored.
492+
493+
Two layers, deliberately. The validation guards one write path; the helper guards the render, for
494+
a row that arrived by import, by console, or from a database restored from before the validation
495+
existed. A dangerous URL renders as **plain text rather than nothing**, because the reader should
496+
still see what the field holds — a bank looking at a nonsense URL is how it gets corrected.
497+
474498
<a id="inert-on-arrival"></a>
475499
- **A control that leads nowhere is disabled only when pressing it would cost something.** Several
476500
can be pressed before they can do anything: Today on a calendar that opens on today, "Reset

0 commit comments

Comments
 (0)