| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | |
| 23 | |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | |
| 30 | |
| 31 | |
| 32 | |
| 33 | |
| 34 | |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | use hmac::{Hmac, Mac}; |
| 51 | use serde::Deserialize; |
| 52 | use sha2::Sha256; |
| 53 | use subtle::ConstantTimeEq; |
| 54 | use topcoat::{ |
| 55 | Result, |
| 56 | context::{Cx, app_context}, |
| 57 | router::{Body, Response, StatusCode}, |
| 58 | session, |
| 59 | view::{component, view}, |
| 60 | }; |
| 61 | |
| 62 | type HmacSha256 = Hmac<Sha256>; |
| 63 | |
| 64 | |
| 65 | pub const CSRF_FIELD: &str = "csrf_token"; |
| 66 | |
| 67 | |
| 68 | |
| 69 | |
| 70 | pub const REFUSED: &str = "This form was submitted from somewhere else. Go back and try again."; |
| 71 | |
| 72 | |
| 73 | |
| 74 | |
| 75 | |
| 76 | |
| 77 | pub const EXEMPT: &[(&str, &str)] = &[ |
| 78 | ( |
| 79 | "/{handle}/repos/{repo}/git-upload-pack", |
| 80 | "git transport: HTTP Basic per request, no cookie and no form", |
| 81 | ), |
| 82 | ( |
| 83 | "/{handle}/repos/{repo}/git-receive-pack", |
| 84 | "git transport: HTTP Basic per request, no cookie and no form", |
| 85 | ), |
| 86 | ( |
| 87 | "/api/setup", |
| 88 | "JSON a provisioner POSTs, never a browser carrying a session", |
| 89 | ), |
| 90 | ( |
| 91 | "/auth/setup", |
| 92 | "submitted signed out: no session to bind a token to", |
| 93 | ), |
| 94 | ( |
| 95 | "/auth/login", |
| 96 | "submitted signed out: no session to bind a token to", |
| 97 | ), |
| 98 | ( |
| 99 | "/auth/forgot", |
| 100 | "submitted signed out: no session to bind a token to", |
| 101 | ), |
| 102 | ( |
| 103 | "/auth/reset/{token}", |
| 104 | "submitted signed out, and the URL's own token is the unguessable part", |
| 105 | ), |
| 106 | ]; |
| 107 | |
| 108 | |
| 109 | |
| 110 | |
| 111 | |
| 112 | pub struct CsrfSecret([u8; 32]); |
| 113 | |
| 114 | impl CsrfSecret { |
| 115 | |
| 116 | #[must_use] |
| 117 | pub fn generate() -> Self { |
| 118 | use rand::Rng; |
| 119 | |
| 120 | let mut bytes = [0u8; 32]; |
| 121 | rand::rng().fill_bytes(&mut bytes); |
| 122 | |
| 123 | Self(bytes) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | impl std::fmt::Debug for CsrfSecret { |
| 128 | |
| 129 | |
| 130 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 131 | f.write_str("CsrfSecret(redacted)") |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | |
| 136 | |
| 137 | |
| 138 | fn derive(secret: &CsrfSecret, session: &[u8]) -> String { |
| 139 | |
| 140 | let mut mac = HmacSha256::new_from_slice(&secret.0).expect("hmac takes any key"); |
| 141 | mac.update(session); |
| 142 | |
| 143 | mac.finalize() |
| 144 | .into_bytes() |
| 145 | .iter() |
| 146 | .map(|byte| format!("{byte:02x}")) |
| 147 | .collect() |
| 148 | } |
| 149 | |
| 150 | |
| 151 | |
| 152 | |
| 153 | |
| 154 | fn matches(expected: &str, presented: &str) -> bool { |
| 155 | expected.as_bytes().ct_eq(presented.as_bytes()).unwrap_u8() == 1 |
| 156 | } |
| 157 | |
| 158 | |
| 159 | async fn token(cx: &Cx) -> Result<Option<String>> { |
| 160 | let Some(hash) = session::token_hash(cx).await? else { |
| 161 | return Ok(None); |
| 162 | }; |
| 163 | |
| 164 | Ok(Some(derive(app_context::<CsrfSecret>(cx), &*hash))) |
| 165 | } |
| 166 | |
| 167 | |
| 168 | |
| 169 | |
| 170 | |
| 171 | |
| 172 | |
| 173 | |
| 174 | #[component] |
| 175 | pub async fn csrf_field(cx: &Cx) -> Result { |
| 176 | let token = token(cx).await?.unwrap_or_default(); |
| 177 | |
| 178 | view! { <input type="hidden" name=(CSRF_FIELD) value=(token.as_str()) /> } |
| 179 | } |
| 180 | |
| 181 | |
| 182 | |
| 183 | |
| 184 | |
| 185 | |
| 186 | |
| 187 | |
| 188 | |
| 189 | |
| 190 | |
| 191 | |
| 192 | |
| 193 | pub async fn csrf_verified(cx: &Cx, presented: &str) -> Result<()> { |
| 194 | match token(cx).await? { |
| 195 | Some(expected) if matches(&expected, presented) => Ok(()), |
| 196 | _ => { |
| 197 | eprintln!("steid: refused a form submission with no valid CSRF token"); |
| 198 | Err(CsrfRefused.into()) |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | |
| 204 | |
| 205 | |
| 206 | |
| 207 | |
| 208 | |
| 209 | |
| 210 | |
| 211 | |
| 212 | |
| 213 | |
| 214 | |
| 215 | |
| 216 | |
| 217 | |
| 218 | |
| 219 | pub async fn csrf_verified_response(cx: &Cx, presented: &str) -> Result<Option<Response>> { |
| 220 | match csrf_verified(cx, presented).await { |
| 221 | Ok(()) => Ok(None), |
| 222 | Err(error) => { |
| 223 | if error.downcast_ref::<CsrfRefused>().is_some() { |
| 224 | let mut response = Response::new(Body::from(format!("{REFUSED}\n"))); |
| 225 | *response.status_mut() = StatusCode::FORBIDDEN; |
| 226 | Ok(Some(response)) |
| 227 | } else { |
| 228 | Err(error) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | |
| 235 | |
| 236 | |
| 237 | |
| 238 | |
| 239 | #[derive(Debug)] |
| 240 | pub struct CsrfRefused; |
| 241 | |
| 242 | impl std::fmt::Display for CsrfRefused { |
| 243 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 244 | f.write_str("the form did not carry this session's CSRF token") |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | impl std::error::Error for CsrfRefused {} |
| 249 | |
| 250 | |
| 251 | |
| 252 | |
| 253 | |
| 254 | |
| 255 | #[derive(Debug, Deserialize)] |
| 256 | pub struct CsrfGuard { |
| 257 | |
| 258 | |
| 259 | |
| 260 | #[serde(default)] |
| 261 | pub csrf_token: String, |
| 262 | } |
| 263 | |
| 264 | #[cfg(test)] |
| 265 | mod tests { |
| 266 | use super::*; |
| 267 | |
| 268 | |
| 269 | |
| 270 | fn secret(byte: u8) -> CsrfSecret { |
| 271 | CsrfSecret([byte; 32]) |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn a_token_is_the_hmac_of_the_session_under_the_secret() { |
| 276 | |
| 277 | |
| 278 | assert_eq!( |
| 279 | derive(&secret(0x01), b"sess"), |
| 280 | "8a705977f42fbbc2a31942039d841760ec5de4c812f88e983674c0a5b44f61f7" |
| 281 | ); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn two_sessions_get_different_tokens_under_one_secret() { |
| 286 | |
| 287 | |
| 288 | assert_ne!(derive(&secret(0x01), b"one"), derive(&secret(0x01), b"two")); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn two_boots_get_different_tokens_for_one_session() { |
| 293 | |
| 294 | |
| 295 | assert_ne!(derive(&secret(0x01), b"one"), derive(&secret(0x02), b"one")); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn a_token_verifies_against_itself_and_nothing_else() { |
| 300 | let token = derive(&secret(0x01), b"sess"); |
| 301 | |
| 302 | assert!(matches(&token, &token)); |
| 303 | assert!(!matches(&token, "")); |
| 304 | assert!(!matches(&token, &token[..token.len() - 1])); |
| 305 | assert!(!matches(&token, &format!("{token}0"))); |
| 306 | assert!(!matches(&token, &derive(&secret(0x01), b"other"))); |
| 307 | } |
| 308 | |
| 309 | #[test] |
| 310 | fn the_secret_does_not_print_itself() { |
| 311 | |
| 312 | assert_eq!(format!("{:?}", secret(0x01)), "CsrfSecret(redacted)"); |
| 313 | } |
| 314 | |
| 315 | #[test] |
| 316 | fn every_exemption_says_why() { |
| 317 | for (route, reason) in EXEMPT { |
| 318 | assert!(route.starts_with('/'), "{route} is not a route"); |
| 319 | assert!(!reason.is_empty(), "{route} is exempt for no stated reason"); |
| 320 | } |
| 321 | } |
| 322 | } |