Skip to content

Commit b0b098d

Browse files
sethherrclaude
andcommitted
Extract CSP report classification into CspReport
The job's own work is three lines; everything else was stateless domain logic about what a report means, which is what CspPolicy already models. Its specs no longer need a job harness, WebMock and a production Rails.env to ask whether a report is noise. Also scope the fetch-error filter to an actual navigation. Failed to fetch and Load failed are what browsers report for any rejected fetch, so the filter was suppressing the genuine failures the retry UI was just built for. A pagehide flag distinguishes them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0a1e7fa commit b0b098d

5 files changed

Lines changed: 267 additions & 191 deletions

File tree

app/javascript/application.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ function localizeTime () {
2626
window.timeLocalizer.localize()
2727
}
2828

29+
// A fetch still in flight when the page goes away rejects with a generic network
30+
// error, not an AbortError, so the message alone can't separate it from real
31+
// breakage - only treat these phrasings as noise while we're actually leaving.
32+
const NAVIGATION_FETCH_ERROR = /Failed to fetch|Load failed|Fetch is aborted|aborted a request/
33+
let navigatingAway = false
34+
// pagehide rather than beforeunload, which costs the page its bfcache entry
35+
window.addEventListener('pagehide', () => { navigatingAway = true })
36+
window.addEventListener('pageshow', () => { navigatingAway = false })
37+
2938
// Load honeybadger dynamically so ad blockers don't break the entire app
3039
const honeybadgerApiKey = document.querySelector('meta[name="honeybadger-api-key"]')?.content
3140
if (honeybadgerApiKey) {
@@ -44,8 +53,7 @@ if (honeybadgerApiKey) {
4453
if (notice.message?.includes('ResizeObserver loop')) {
4554
return false
4655
}
47-
// A fetch killed by navigating away, in the phrasings browsers give it
48-
if (/Failed to fetch|Load failed|Fetch is aborted|aborted a request/.test(notice.message)) {
56+
if (navigatingAway && NAVIGATION_FETCH_ERROR.test(notice.message)) {
4957
return false
5058
}
5159
})

app/jobs/forward_csp_report_job.rb

Lines changed: 4 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,103 +3,23 @@ class ForwardCspReportJob < ApplicationJob
33

44
HONEYBADGER_URL = "https://api.honeybadger.io/v1/browser/csp"
55
HONEYBADGER_CSP_API_KEY = ENV["HONEYBADGER_CSP_API_KEY"]
6-
EXTENSION_SCHEME = %r{\A(chrome|moz|safari|safari-web)-extension://}
7-
IN_APP_BROWSER = /\b(FBAN|FBAV|FB_IAB|Instagram|Line\/)\b/
8-
TRANSLATE_DOCUMENT = /\.translate\.goog\z|translate\.google(apis)?\.com/
9-
# Google Ads conversion iframes load on country-specific google.<tld> domains;
10-
# CSP frame_src allowlists common ones, we silence reports for the rest. Frame
11-
# violations report the bare origin, hence the trailing slash-or-end.
12-
GOOGLE_FRAME = %r{\Ahttps://www\.google\.[a-z.]+(/|\z)}
13-
# Corporate proxies, antivirus, and carriers inject frames pointing at private
14-
# or loopback IPs — the user's own network, nothing we serve or can fix.
15-
PRIVATE_IP_FRAME = %r{\Ahttps?://(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)}
16-
BLOCKED_URI_NOISE = Regexp.union(GOOGLE_FRAME, PRIVATE_IP_FRAME)
176

187
# The query is rebuilt here, not forwarded from the client, so the API key and
198
# user context never ride in the browser-facing CSP report_uri.
209
def perform(body, user_id, user_agent = nil)
2110
# dev/sandbox browsers still emit reports; only production forwards to Honeybadger
2211
return unless Rails.env.production? && HONEYBADGER_CSP_API_KEY.present?
2312

24-
report = parsed_report(body)
25-
return unless forward?(report, user_agent)
13+
report = CspReport.parse(body)
14+
return if report.blank? || CspReport.noise?(report, user_agent)
2615

2716
# Honeybadger records this request's user agent, which is Faraday - so the
2817
# browser's rides along as context, or a report can't be attributed at all
2918
query = URI.encode_www_form(api_key: HONEYBADGER_CSP_API_KEY, report_only: false,
3019
env: Rails.env, "context[user_id]": user_id.to_s,
3120
"context[user_agent]": user_agent.to_s)
32-
Faraday.post("#{HONEYBADGER_URL}?#{query}", normalized_body(report),
21+
Faraday.post("#{HONEYBADGER_URL}?#{query}",
22+
{"csp-report" => CspReport.normalize(report)}.to_json,
3323
"Content-Type" => "application/csp-report")
3424
end
35-
36-
private
37-
38-
# Honeybadger fingerprints a fault on the whole blocked-uri, so a query string
39-
# that varies per request mints a new fault every time — one ad conversion url
40-
# accounted for hundreds of them.
41-
def normalized_body(report)
42-
uri = parsed_uri(report["blocked-uri"])
43-
normalized = uri ? "#{uri.scheme}://#{uri.host}#{uri.path}" : report["blocked-uri"]
44-
{"csp-report" => report.merge("blocked-uri" => normalized)}.to_json
45-
end
46-
47-
def parsed_report(body)
48-
parsed = JSON.parse(body)
49-
parsed["csp-report"] if parsed.is_a?(Hash) && parsed["csp-report"].is_a?(Hash)
50-
rescue JSON::ParserError, TypeError
51-
nil
52-
end
53-
54-
def forward?(report, user_agent)
55-
report.present? && !user_agent.to_s.match?(IN_APP_BROWSER) &&
56-
!extension_noise?(report) && !translate_noise?(report) &&
57-
!report["blocked-uri"].to_s.match?(BLOCKED_URI_NOISE) &&
58-
!third_party_font_noise?(report) && !foreign_policy_noise?(report)
59-
end
60-
61-
def extension_noise?(report)
62-
[report["blocked-uri"], report["source-file"]].compact
63-
.any? { |uri| uri.match?(EXTENSION_SCHEME) }
64-
end
65-
66-
# Google Translate reskins the page and injects read-aloud TTS audio as data: media
67-
def translate_noise?(report)
68-
return true if report["document-uri"].to_s.match?(TRANSLATE_DOCUMENT)
69-
report["effective-directive"] == "media-src" && report["blocked-uri"].to_s == "data"
70-
end
71-
72-
# An extension that tightens our response header still reports to our report-uri,
73-
# so a cross-origin block our own policy would have permitted wasn't ours. Same
74-
# origin is exempt: 'self' always permits it, and what the browser is actually
75-
# reporting is a redirect to a target it won't name.
76-
def foreign_policy_noise?(report)
77-
uri = cross_origin_blocked_uri(report)
78-
uri && CspPolicy.permits?(directive(report), uri)
79-
end
80-
81-
# Every font we load is on our own origin or in font_src, so a font blocked from
82-
# a third party was injected into the page — coupon and citation extensions add
83-
# page-level <link>s, which carry an https uri EXTENSION_SCHEME can't recognize.
84-
# The cost is that a webfont host we forget to allowlist goes unreported.
85-
def third_party_font_noise?(report)
86-
directive(report) == "font-src" && cross_origin_blocked_uri(report).present?
87-
end
88-
89-
# Old browsers send the sources along with the name: "font-src https://x"
90-
def directive(report)
91-
(report["effective-directive"].presence || report["violated-directive"]).to_s.split.first
92-
end
93-
94-
def cross_origin_blocked_uri(report)
95-
uri = parsed_uri(report["blocked-uri"])
96-
uri if uri && uri.host != parsed_uri(report["document-uri"])&.host
97-
end
98-
99-
def parsed_uri(value)
100-
uri = URI.parse(value.to_s)
101-
uri if uri.is_a?(URI::HTTP) && uri.host.present?
102-
rescue URI::InvalidURIError
103-
nil
104-
end
10525
end

app/services/csp_report.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
# Parses a browser CSP report and decides whether it describes something we can
4+
# act on. CspPolicy answers what our own policy permits; this decides what's
5+
# worth forwarding, and normalizes what is.
6+
module CspReport
7+
extend Functionable
8+
9+
EXTENSION_SCHEME = %r{\A(chrome|moz|safari|safari-web)-extension://}
10+
IN_APP_BROWSER = /\b(FBAN|FBAV|FB_IAB|Instagram|Line\/)\b/
11+
TRANSLATE_DOCUMENT = /\.translate\.goog\z|translate\.google(apis)?\.com/
12+
# Google Ads conversion iframes load on country-specific google.<tld> domains;
13+
# CSP frame_src allowlists common ones, we silence reports for the rest. Frame
14+
# violations report the bare origin, hence the trailing slash-or-end.
15+
GOOGLE_FRAME = %r{\Ahttps://www\.google\.[a-z.]+(/|\z)}
16+
# Corporate proxies, antivirus, and carriers inject frames pointing at private
17+
# or loopback IPs — the user's own network, nothing we serve or can fix.
18+
PRIVATE_IP_FRAME = %r{\Ahttps?://(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)}
19+
BLOCKED_URI_NOISE = Regexp.union(GOOGLE_FRAME, PRIVATE_IP_FRAME)
20+
21+
def parse(body)
22+
parsed = JSON.parse(body)
23+
parsed["csp-report"] if parsed.is_a?(Hash) && parsed["csp-report"].is_a?(Hash)
24+
rescue JSON::ParserError, TypeError
25+
nil
26+
end
27+
28+
def noise?(report, user_agent = nil)
29+
user_agent.to_s.match?(IN_APP_BROWSER) || extension_noise?(report) ||
30+
translate_noise?(report) || report["blocked-uri"].to_s.match?(BLOCKED_URI_NOISE) ||
31+
third_party_font_noise?(report) || foreign_policy_noise?(report)
32+
end
33+
34+
# Honeybadger fingerprints a fault on the whole blocked-uri, so a query string
35+
# that varies per request mints a new fault every time — one ad conversion url
36+
# accounted for hundreds of them.
37+
def normalize(report)
38+
uri = parsed_uri(report["blocked-uri"])
39+
return report if uri.nil?
40+
41+
report.merge("blocked-uri" => "#{uri.scheme}://#{uri.host}#{uri.path}")
42+
end
43+
44+
#
45+
# private below here
46+
#
47+
48+
def extension_noise?(report)
49+
[report["blocked-uri"], report["source-file"]].compact
50+
.any? { |uri| uri.match?(EXTENSION_SCHEME) }
51+
end
52+
53+
# Google Translate reskins the page and injects read-aloud TTS audio as data: media
54+
def translate_noise?(report)
55+
return true if report["document-uri"].to_s.match?(TRANSLATE_DOCUMENT)
56+
report["effective-directive"] == "media-src" && report["blocked-uri"].to_s == "data"
57+
end
58+
59+
# An extension that tightens our response header still reports to our report-uri,
60+
# so a cross-origin block our own policy would have permitted wasn't ours. Same
61+
# origin is exempt: 'self' always permits it, and what the browser is actually
62+
# reporting is a redirect to a target it won't name.
63+
def foreign_policy_noise?(report)
64+
uri = cross_origin_blocked_uri(report)
65+
uri && CspPolicy.permits?(directive(report), uri)
66+
end
67+
68+
# Every font we load is on our own origin or in font_src, so a font blocked from
69+
# a third party was injected into the page — coupon and citation extensions add
70+
# page-level <link>s, which carry an https uri EXTENSION_SCHEME can't recognize.
71+
# The cost is that a webfont host we forget to allowlist goes unreported.
72+
def third_party_font_noise?(report)
73+
directive(report) == "font-src" && cross_origin_blocked_uri(report).present?
74+
end
75+
76+
# Old browsers send the sources along with the name: "font-src https://x"
77+
def directive(report)
78+
(report["effective-directive"].presence || report["violated-directive"]).to_s.split.first
79+
end
80+
81+
def cross_origin_blocked_uri(report)
82+
uri = parsed_uri(report["blocked-uri"])
83+
uri if uri && uri.host != parsed_uri(report["document-uri"])&.host
84+
end
85+
86+
def parsed_uri(value)
87+
uri = URI.parse(value.to_s)
88+
uri if uri.is_a?(URI::HTTP) && uri.host.present?
89+
rescue URI::InvalidURIError
90+
nil
91+
end
92+
93+
conceal :extension_noise?, :translate_noise?, :foreign_policy_noise?,
94+
:third_party_font_noise?, :directive, :cross_origin_blocked_uri, :parsed_uri
95+
end

spec/jobs/forward_csp_report_job_spec.rb

Lines changed: 6 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
RSpec.describe ForwardCspReportJob, type: :job do
44
let(:blocked_uri) { "https://evil.example.com/x.js" }
5-
let(:effective_directive) { "script-src" }
65
let(:document_uri) { "https://bikeindex.org/bikes/1" }
7-
let(:report) { {"csp-report" => {"blocked-uri" => blocked_uri, "document-uri" => document_uri, "effective-directive" => effective_directive}} }
6+
let(:report) { {"csp-report" => {"blocked-uri" => blocked_uri, "document-uri" => document_uri, "effective-directive" => "script-src"}} }
87
let(:body) { report.to_json }
98
let(:user_agent) { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/148.0.0.0 Safari/537.36" }
109
let(:forwarded) { [] }
@@ -46,9 +45,9 @@ def perform(request_body = body)
4645
end
4746

4847
context "blocked-uri with a query string" do
49-
let(:blocked_uri) { "https://evil.example.com/x.js?label=WADeCPKg7gYQtvfc0wM&guid=ON" }
48+
let(:blocked_uri) { "https://evil.example.com/x.js?label=WADeCPKg7gYQtvfc0wM" }
5049

51-
it "forwards it without the query, so the fault doesn't fingerprint per-request" do
50+
it "forwards it normalized" do
5251
perform
5352
expect(forwarded_blocked_uri).to eq "https://evil.example.com/x.js"
5453
end
@@ -62,118 +61,20 @@ def perform(request_body = body)
6261
end
6362
end
6463

65-
context "browser-extension noise" do
64+
context "a report CspReport treats as noise" do
6665
let(:blocked_uri) { "chrome-extension://0dca8e62/fonts/Inter-Variable.ttf" }
6766
it "does not forward" do
6867
perform
6968
expect(forwarded).to be_empty
7069
end
7170
end
7271

73-
context "in-app browser user agent" do
74-
let(:user_agent) { "Mozilla/5.0 (Linux; Android 12) Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/488.0.0.78.79;]" }
75-
it "does not forward" do
76-
perform
77-
expect(forwarded).to be_empty
78-
end
79-
end
80-
81-
context "google country-domain frame (origin only, no path)" do
82-
let(:blocked_uri) { "https://www.google.co.id" }
83-
it "does not forward" do
84-
perform
85-
expect(forwarded).to be_empty
86-
end
87-
end
88-
89-
context "private-ip frame injection" do
90-
let(:blocked_uri) { "https://10.255.99.112" }
91-
it "does not forward" do
92-
perform
93-
expect(forwarded).to be_empty
94-
end
95-
end
96-
97-
context "read-aloud data: media" do
98-
let(:blocked_uri) { "data" }
99-
let(:effective_directive) { "media-src" }
100-
it "does not forward" do
101-
perform
102-
expect(forwarded).to be_empty
103-
end
104-
end
105-
106-
context "blocked by a policy that isn't ours" do
107-
# An extension tightening our header still reports to our report-uri
108-
let(:blocked_uri) { "https://www.googletagmanager.com/gtm.js?id=GTM-K88RMWC" }
109-
let(:effective_directive) { "script-src-elem" }
110-
111-
it "does not forward" do
112-
perform
113-
expect(forwarded).to be_empty
114-
end
115-
end
116-
117-
context "an old browser sending the sources alongside the directive name" do
118-
let(:report) { {"csp-report" => {"blocked-uri" => "https://www.google-analytics.com/analytics.js", "document-uri" => document_uri, "violated-directive" => "script-src https://www.googletagmanager.com"}} }
119-
120-
it "does not forward" do
121-
perform
122-
expect(forwarded).to be_empty
123-
end
124-
end
125-
126-
context "a host our policy does not allow" do
127-
let(:blocked_uri) { "https://images.simplycodes.com/tracker.gif" }
128-
let(:effective_directive) { "img-src" }
129-
130-
it "forwards" do
131-
perform
132-
expect(forwarded_blocked_uri).to eq blocked_uri
133-
end
134-
end
135-
136-
context "a font from a third party" do
137-
# Coupon and citation extensions inject page-level <link>s
138-
let(:blocked_uri) { "https://images.simplycodes.com/fonts/CircularXXWeb-Medium.woff2" }
139-
let(:effective_directive) { "font-src" }
140-
72+
context "malformed body" do
14173
it "does not forward" do
142-
perform
74+
perform("not json")
14375
expect(forwarded).to be_empty
14476
end
14577
end
146-
147-
context "a font from our own origin" do
148-
let(:blocked_uri) { "https://bikeindex.org/assets/inter.woff2" }
149-
let(:effective_directive) { "font-src" }
150-
151-
it "forwards" do
152-
perform
153-
expect(forwarded_blocked_uri).to eq blocked_uri
154-
end
155-
end
156-
157-
context "same-origin blocked-uri our policy would allow" do
158-
# 'self' permits it, so this is a redirect to a target the browser won't name
159-
let(:blocked_uri) { "https://bikeindex.org/payments" }
160-
let(:document_uri) { "https://bikeindex.org/donate" }
161-
let(:effective_directive) { "connect-src" }
162-
163-
it "forwards" do
164-
perform
165-
expect(forwarded_blocked_uri).to eq blocked_uri
166-
end
167-
end
168-
169-
context "malformed body" do
170-
["not json", "null", "123", {"csp-report" => 5}.to_json].each do |raw_body|
171-
it "does not forward for #{raw_body.inspect}" do
172-
perform(raw_body)
173-
expect(forwarded).to be_empty
174-
end
175-
end
176-
end
17778
end
17879
end
17980
end

0 commit comments

Comments
 (0)