Skip to content

Commit 7c52375

Browse files
authored
Reject javascript: and vbscript: URI schemes in href (#4113)
1 parent bf92d87 commit 7c52375

8 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/view-components": patch
3+
---
4+
5+
Reject `javascript:` and `vbscript:` URI schemes in `href` (defense in depth). When a component (e.g. `Primer::Beta::Label`, `Primer::Beta::Button`, `Primer::Beta::Link`) is rendered as an anchor with an unsafe `href`, the value is now rejected — raising in non-production environments and silently dropped (rendered as an anchor with no `href`) in production.

app/components/primer/base_component.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ def initialize(tag:, classes: nil, **system_arguments)
156156
@tag = tag
157157

158158
@system_arguments = validate_arguments(tag: tag, **system_arguments)
159+
sanitize_href!(@system_arguments)
159160

160161
@result = Primer::Classify.call(**@system_arguments.merge(classes: classes))
161162

app/components/primer/component.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Component < ViewComponent::Base
2222
include AttributesHelper
2323
include ClassNameHelper
2424
include FetchOrFallbackHelper
25+
include SafeHrefHelper
2526
include TestSelectorHelper
2627
include JoinStyleArgumentsHelper
2728
include ViewHelper
@@ -137,6 +138,21 @@ def deny_tag_argument(**arguments)
137138
deny_single_argument(:tag, "This component has a fixed tag.", **arguments)
138139
end
139140

141+
# Removes `href` values that point at disallowed URI schemes (`javascript:`,
142+
# `vbscript:`). Raises in non-production environments so the offending call
143+
# site gets fixed; in production we silently drop the attribute so the link
144+
# is rendered inert rather than crashing the page.
145+
def sanitize_href!(arguments)
146+
return unless arguments.key?(:href)
147+
return unless Primer::SafeHrefHelper.unsafe_href?(arguments[:href])
148+
149+
if should_raise_error?
150+
raise ArgumentError, "Rejected dangerous URI scheme in `href`: #{arguments[:href].inspect}"
151+
end
152+
153+
arguments[:href] = nil
154+
end
155+
140156
def should_raise_error?
141157
!Rails.env.production? && raise_on_invalid_options? && !ENV["PRIMER_WARNINGS_DISABLED"]
142158
end

app/lib/primer/safe_href_helper.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
# Primer::SafeHrefHelper
4+
#
5+
# Detects unsafe URI schemes (e.g. `javascript:`) in `href` values so they can
6+
# be neutralized before being rendered into HTML attributes. Without this check,
7+
# a caller that forwards untrusted input into a component's `href:` argument can
8+
# trigger XSS when the user clicks the rendered anchor.
9+
module Primer
10+
# :nodoc:
11+
module SafeHrefHelper
12+
# URI schemes that can execute script in the browser and are never valid
13+
# destinations for a Primer-rendered link.
14+
DISALLOWED_HREF_SCHEMES = %w[javascript vbscript].freeze
15+
16+
# Returns true when `href` starts with a disallowed URI scheme.
17+
#
18+
# Mirrors browser URL parsing by stripping ASCII whitespace and control
19+
# characters (including tab/CR/LF) before extracting the scheme. This
20+
# prevents bypasses such as `j\tavascript:...`, ` JaVaScRiPt:...`, or a
21+
# leading null byte, all of which browsers happily execute.
22+
def self.unsafe_href?(href)
23+
return false if href.nil?
24+
25+
normalized = href.to_s.gsub(/[\u0000-\u0020]/, "")
26+
scheme = normalized[/\A([a-z][a-z0-9+\-.]*):/i, 1]
27+
return false unless scheme
28+
29+
DISALLOWED_HREF_SCHEMES.include?(scheme.downcase)
30+
end
31+
32+
def unsafe_href?(href)
33+
SafeHrefHelper.unsafe_href?(href)
34+
end
35+
end
36+
end

test/components/base_component_test.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,46 @@ def test_renders_as_a_link
9292
assert_selector("a[href='http://google.com']")
9393
end
9494

95+
def test_rejects_javascript_href_in_non_production
96+
err = assert_raises(ArgumentError) do
97+
render_inline(Primer::BaseComponent.new(tag: :a, href: "javascript:alert(1)"))
98+
end
99+
assert_match(/Rejected dangerous URI scheme/, err.message)
100+
end
101+
102+
def test_neutralizes_javascript_href_in_production
103+
with_raise_on_invalid_options(false) do
104+
render_inline(Primer::BaseComponent.new(tag: :a, href: "javascript:alert(1)")) { "x" }
105+
106+
assert_selector("a")
107+
refute_selector("a[href]")
108+
end
109+
end
110+
111+
def test_neutralizes_vbscript_href_in_production
112+
with_raise_on_invalid_options(false) do
113+
render_inline(Primer::BaseComponent.new(tag: :a, href: "vbscript:msgbox(1)")) { "x" }
114+
115+
refute_selector("a[href]")
116+
end
117+
end
118+
119+
def test_neutralizes_javascript_href_with_whitespace_bypass_attempt
120+
with_raise_on_invalid_options(false) do
121+
render_inline(Primer::BaseComponent.new(tag: :a, href: "\tJaVaScRiPt:alert(1)")) { "x" }
122+
123+
refute_selector("a[href]")
124+
end
125+
end
126+
127+
def test_allows_safe_hrefs
128+
render_inline(Primer::BaseComponent.new(tag: :a, href: "/foo/bar"))
129+
assert_selector("a[href='/foo/bar']")
130+
131+
render_inline(Primer::BaseComponent.new(tag: :a, href: "mailto:hello@example.com"))
132+
assert_selector("a[href='mailto:hello@example.com']")
133+
end
134+
95135
# We were calling tag.send(as), passing in :p ended up calling `p`, aka `puts`
96136
# Due to how Rails uses method_missing in TagHelper. See Slack convo:
97137
# https://github.slack.com/archives/C0HV3F37A/p1556216733019500

test/components/beta/label_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,19 @@ def test_falls_back_when_variant_isn_t_valid
9595
def test_status
9696
assert_component_state(Primer::Beta::Label, :beta)
9797
end
98+
99+
def test_rejects_javascript_href_when_tag_is_anchor
100+
assert_raises(ArgumentError) do
101+
render_inline(Primer::Beta::Label.new(tag: :a, href: "javascript:alert(document.domain)")) { "x" }
102+
end
103+
end
104+
105+
def test_neutralizes_javascript_href_when_tag_is_anchor_in_production
106+
with_raise_on_invalid_options(false) do
107+
render_inline(Primer::Beta::Label.new(tag: :a, href: "javascript:alert(document.domain)")) { "x" }
108+
109+
assert_selector("a.Label")
110+
refute_selector("a.Label[href]")
111+
end
112+
end
98113
end

test/components/primer/beta/button_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,19 @@ def test_warns_on_uses_of_dropdown
8787

8888
assert_equal "The `dropdown:` argument is no longer supported on Primer::Beta::Button. Use the `trailing_action` slot instead.", err.message
8989
end
90+
91+
def test_rejects_javascript_href_when_rendered_as_anchor
92+
assert_raises(ArgumentError) do
93+
render_inline(Primer::Beta::Button.new(tag: :a, href: "javascript:alert(document.domain)")) { "Button" }
94+
end
95+
end
96+
97+
def test_neutralizes_javascript_href_when_rendered_as_anchor_in_production
98+
with_raise_on_invalid_options(false) do
99+
render_inline(Primer::Beta::Button.new(tag: :a, href: "javascript:alert(document.domain)")) { "Button" }
100+
101+
assert_selector("a.Button", text: "Button")
102+
refute_selector("a.Button[href]")
103+
end
104+
end
90105
end

test/lib/safe_href_helper_test.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
require "lib/test_helper"
4+
5+
class Primer::SafeHrefHelperTest < Minitest::Test
6+
include Primer::SafeHrefHelper
7+
8+
def test_returns_false_for_nil
9+
refute Primer::SafeHrefHelper.unsafe_href?(nil)
10+
end
11+
12+
def test_returns_false_for_relative_paths
13+
refute Primer::SafeHrefHelper.unsafe_href?("/foo/bar")
14+
refute Primer::SafeHrefHelper.unsafe_href?("foo/bar")
15+
refute Primer::SafeHrefHelper.unsafe_href?("#anchor")
16+
refute Primer::SafeHrefHelper.unsafe_href?("?query=1")
17+
refute Primer::SafeHrefHelper.unsafe_href?("")
18+
end
19+
20+
def test_returns_false_for_safe_schemes
21+
refute Primer::SafeHrefHelper.unsafe_href?("https://example.com")
22+
refute Primer::SafeHrefHelper.unsafe_href?("http://example.com")
23+
refute Primer::SafeHrefHelper.unsafe_href?("mailto:foo@example.com")
24+
refute Primer::SafeHrefHelper.unsafe_href?("ftp://example.com")
25+
refute Primer::SafeHrefHelper.unsafe_href?("tel:+15551234567")
26+
refute Primer::SafeHrefHelper.unsafe_href?("data:image/png;base64,abc")
27+
end
28+
29+
def test_detects_javascript_uri
30+
assert Primer::SafeHrefHelper.unsafe_href?("javascript:alert(1)")
31+
end
32+
33+
def test_detects_vbscript_uri
34+
assert Primer::SafeHrefHelper.unsafe_href?("vbscript:msgbox(1)")
35+
end
36+
37+
def test_detects_mixed_case_schemes
38+
assert Primer::SafeHrefHelper.unsafe_href?("JaVaScRiPt:alert(1)")
39+
assert Primer::SafeHrefHelper.unsafe_href?("JAVASCRIPT:alert(1)")
40+
assert Primer::SafeHrefHelper.unsafe_href?("VbScript:alert(1)")
41+
end
42+
43+
def test_detects_leading_whitespace_bypasses
44+
assert Primer::SafeHrefHelper.unsafe_href?(" javascript:alert(1)")
45+
assert Primer::SafeHrefHelper.unsafe_href?("\tjavascript:alert(1)")
46+
assert Primer::SafeHrefHelper.unsafe_href?("\njavascript:alert(1)")
47+
assert Primer::SafeHrefHelper.unsafe_href?("\rjavascript:alert(1)")
48+
end
49+
50+
def test_detects_embedded_whitespace_and_control_chars_in_scheme
51+
# Browsers strip tab/CR/LF inside URLs per the WHATWG URL spec, so these execute.
52+
assert Primer::SafeHrefHelper.unsafe_href?("java\tscript:alert(1)")
53+
assert Primer::SafeHrefHelper.unsafe_href?("java\nscript:alert(1)")
54+
assert Primer::SafeHrefHelper.unsafe_href?("java\rscript:alert(1)")
55+
assert Primer::SafeHrefHelper.unsafe_href?("j\u0000avascript:alert(1)")
56+
end
57+
58+
def test_does_not_misclassify_schemes_that_merely_contain_javascript
59+
refute Primer::SafeHrefHelper.unsafe_href?("https://example.com/javascript:foo")
60+
refute Primer::SafeHrefHelper.unsafe_href?("/javascript:foo")
61+
end
62+
63+
def test_handles_non_string_values
64+
refute Primer::SafeHrefHelper.unsafe_href?(123)
65+
refute Primer::SafeHrefHelper.unsafe_href?(:foo)
66+
end
67+
68+
def test_instance_method_delegates_to_module_method
69+
assert unsafe_href?("javascript:alert(1)")
70+
refute unsafe_href?("https://example.com")
71+
end
72+
end

0 commit comments

Comments
 (0)