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