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