d74ce22f Andy McCall 20 days ago ↶
1
2
3
04683947 Andy McCall 9 days ago ↶
4
5
6
7
8
d74ce22f Andy McCall 20 days ago ↶
9
10
2cbf6f34 Andy McCall 19 days ago ↶
11
12
d74ce22f Andy McCall 20 days ago ↶
13
14
use axum:: http:: { header, HeaderName, HeaderValue} ;
15
use axum:: response:: Response;
16
use std:: collections:: HashMap;
17
use std:: sync:: Mutex;
18
19
20
pub const REPORT_PATH : & str = " /csp-report" ;
21
22
04683947 Andy McCall 9 days ago ↶
23
const ENFORCE : bool = true ;
d74ce22f Andy McCall 20 days ago ↶
24
25
26
27
04683947 Andy McCall 9 days ago ↶
28
29
30
31
const POLICY : & str = " default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com; \
32
style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; \
33
connect-src 'self' https://cloudflareinsights.com; media-src 'self'; object-src 'none'; \
d74ce22f Andy McCall 20 days ago ↶
34
base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report; report-to csp" ;
35
36
const PERMISSIONS : & str = " camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), \
37
midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), browsing-topics=(), interest-cohort=()" ;
38
2cbf6f34 Andy McCall 19 days ago ↶
39
40
41
pub fn hsts ( base_url : & str , max_age : u64 ) -> Option< HeaderValue> {
42
( base_url.starts_with ( " https://" ) & & max_age > 0 ) .then ( | | HeaderValue:: from_str( & format! ( " max-age={max_age} " ) ) .ok ( ) ) .flatten ( )
43
}
44
d74ce22f Andy McCall 20 days ago ↶
45
2cbf6f34 Andy McCall 19 days ago ↶
46
pub async fn security_headers (
47
axum:: extract:: State( hsts ) : axum:: extract:: State< Option< HeaderValue> > ,
48
req : axum:: extract:: Request,
49
next : axum:: middleware:: Next,
50
) -> Response {
d74ce22f Andy McCall 20 days ago ↶
51
let mut res = next.run ( req) .await;
52
let h = res.headers_mut ( ) ;
2cbf6f34 Andy McCall 19 days ago ↶
53
if let Some ( v) = hsts {
54
h.entry ( header:: STRICT_TRANSPORT_SECURITY ) .or_insert ( v) ;
55
}
d74ce22f Andy McCall 20 days ago ↶
56
let own_policy = h.contains_key ( header:: CONTENT_SECURITY_POLICY ) ;
57
let mut set = | name : HeaderName, value : & 'static str | {
58
h.entry ( name) .or_insert ( HeaderValue:: from_static( value) ) ;
59
} ;
60
set ( header:: X_CONTENT_TYPE_OPTIONS , " nosniff" ) ;
61
set ( header:: REFERRER_POLICY , " strict-origin-when-cross-origin" ) ;
62
set ( HeaderName:: from_static( " permissions-policy" ) , PERMISSIONS ) ;
63
if ! own_policy {
64
set ( header:: X_FRAME_OPTIONS , " DENY" ) ;
65
set ( HeaderName:: from_static( " reporting-endpoints" ) , " csp=\" /csp-report\" " ) ;
66
let name = if ENFORCE { header:: CONTENT_SECURITY_POLICY } else { header:: CONTENT_SECURITY_POLICY_REPORT_ONLY } ;
67
set ( name, POLICY ) ;
68
}
69
res
70
}
71
72
73
# [ derive ( Debug, PartialEq ) ]
74
pub struct Violation {
75
pub page : String,
76
pub directive : String,
77
pub blocked : String,
78
pub source : String,
79
}
80
81
82
83
pub fn violations ( body : & [u8 ]) -> Vec< Violation> {
84
let Ok ( v) = serde_json:: from_slice:: < serde_json:: Value> ( body) else { return vec! [ ] } ;
85
let s = | v : & serde_json:: Value, keys : & [& str ]| {
86
keys.iter ( ) .find_map ( | k | v.get ( * k) .and_then ( | x | x.as_str ( ) .map ( String :: from) .or_else ( | | x.as_u64 ( ) .map ( | n | n.to_string ( ) ) ) ) )
87
} ;
88
let one = | r : & serde_json:: Value, page : & [& str ], dir : & [& str ], blocked : & [& str ], file : & [& str ], line : & [& str ]| Violation {
89
page: s ( r, page) .unwrap_or_default ( ) ,
90
directive: s ( r, dir) .unwrap_or_default ( ) ,
91
blocked: s ( r, blocked) .unwrap_or_default ( ) ,
92
source: match ( s ( r, file) , s ( r, line) ) {
93
( Some ( f) , Some ( l) ) => format! ( " {f} :{l} " ) ,
94
( Some ( f) , None ) => f,
95
_ => String :: new( ) ,
96
} ,
97
} ;
98
if let Some ( r) = v.get ( " csp-report" ) {
99
return vec! [ one (
100
r,
101
& [ " document-uri" ] ,
102
& [ " effective-directive" , " violated-directive" ] ,
103
& [ " blocked-uri" ] ,
104
& [ " source-file" ] ,
105
& [ " line-number" ] ,
106
) ] ;
107
}
108
v.as_array ( )
109
.map ( | list | {
110
list.iter ( )
111
.filter ( | r | r.get ( " type" ) .and_then ( | t | t.as_str ( ) ) = = Some ( " csp-violation" ) )
112
.filter_map ( | r | r.get ( " body" ) )
113
.map ( | b | one ( b, & [ " documentURL" ] , & [ " effectiveDirective" ] , & [ " blockedURL" ] , & [ " sourceFile" ] , & [ " lineNumber" ] ) )
114
.collect ( )
115
} )
116
.unwrap_or_default ( )
117
}
118
119
120
121
static SEEN : Mutex< Option< HashMap< String, i64 > > > = Mutex:: new( None ) ;
122
const LOG_EVERY_MS : i64 = 60 * 60_000 ;
123
124
fn first_in_a_while ( key : String, now_ms : i64 ) -> bool {
125
let mut seen = SEEN .lock ( ) .unwrap ( ) ;
126
let seen = seen.get_or_insert_with ( HashMap:: new) ;
127
if seen.len ( ) > 1000 {
128
seen.retain ( | _, t | now_ms - * t < LOG_EVERY_MS ) ;
129
}
130
match seen.get ( & key) {
131
Some ( t) if now_ms - t < LOG_EVERY_MS => false ,
132
_ => {
133
seen.insert ( key, now_ms) ;
134
true
135
}
136
}
137
}
138
139
140
pub async fn csp_report ( body : axum:: body:: Bytes) -> axum:: http:: StatusCode {
141
let clip = | s : & str | s.chars ( ) .filter ( | c | ! c.is_control ( ) ) .take ( 200 ) .collect:: < String> ( ) ;
142
for v in violations ( & body) {
143
144
let page = v.page.split ( [ ' ?' , ' #' ] ) .next ( ) .unwrap_or ( " " ) .to_string ( ) ;
145
let key = format! ( " {} |{} |{} " , v.directive, v.blocked, v.source) ;
146
if first_in_a_while ( key, crate :: db:: now_ms( ) ) {
147
tracing:: warn! ( " csp would block: {} {} on {} ({})" , clip ( & v.directive) , clip ( & v.blocked) , clip ( & page) , clip ( & v.source) ) ;
148
}
149
}
150
axum:: http:: StatusCode:: NO_CONTENT
151
}
152
153
# [ cfg ( test ) ]
154
mod tests {
155
use super :: * ;
156
157
# [ test ]
158
fn both_report_formats_are_read ( ) {
159
let old = br # "{"csp-report":{"document-uri":"https://hub.example/a?b","violated-directive":"script-src-elem","blocked-uri":"inline","source-file":"https://hub.example/a","line-number":12}}"# ;
160
assert_eq! (
161
violations ( old) ,
162
vec! [ Violation {
163
page: " https://hub.example/a?b" .into ( ) ,
164
directive: " script-src-elem" .into ( ) ,
165
blocked: " inline" .into ( ) ,
166
source: " https://hub.example/a:12" .into ( )
167
} ]
168
) ;
169
let new = br # "[{"type":"csp-violation","url":"https://hub.example/x","body":{"documentURL":"https://hub.example/x","effectiveDirective":"img-src","blockedURL":"http://cdn.example/i.png"}},{"type":"deprecation","body":{}}]"# ;
170
let v = violations ( new) ;
171
assert_eq! ( v.len ( ) , 1 ) ;
172
assert_eq! ( ( v[ 0 ] .directive.as_str ( ) , v[ 0 ] .blocked.as_str ( ) , v[ 0 ] .source.as_str ( ) ) , ( " img-src" , " http://cdn.example/i.png" , " " ) ) ;
173
assert! ( violations ( b " not json" ) .is_empty ( ) ) ;
174
}
175
176
# [ test ]
177
fn a_violation_is_logged_once_an_hour ( ) {
178
let k = | | " script-src|inline|test-only" .to_string ( ) ;
179
assert! ( first_in_a_while ( k ( ) , 1_000 ) ) ;
180
assert! ( ! first_in_a_while ( k ( ) , 2_000 ) ) ;
181
assert! ( first_in_a_while ( k ( ) , 1_000 + LOG_EVERY_MS ) ) ;
182
}
183
}