@@ -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{\A https://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{\A https?://(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
10525end
0 commit comments