diff --git a/apps/legacy/package.json b/apps/legacy/package.json index 23a747b74..a56b89417 100644 --- a/apps/legacy/package.json +++ b/apps/legacy/package.json @@ -7,6 +7,7 @@ "dependencies": { "@boluo/api": "0.0.0", "@boluo/backend-proxy": "0.0.0", + "@boluo/utils": "0.0.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@hello-pangea/dnd": "^18.0.1", diff --git a/apps/legacy/src/api/request.ts b/apps/legacy/src/api/request.ts index 1e79936ed..8301d33d4 100644 --- a/apps/legacy/src/api/request.ts +++ b/apps/legacy/src/api/request.ts @@ -3,6 +3,7 @@ import { isCrossOrigin } from '../settings'; import store from '../store'; import { Err, Ok, type Result } from '../utils/result'; import { getAuthToken, clearAuthToken } from '../utils/token'; +import { applyCsrfHeader } from '@boluo/utils/csrf'; import type { IdQuery, IdWithToken, @@ -99,6 +100,7 @@ export const request = async ( if (token) { headers.append('Authorization', `Bearer ${token}`); } + applyCsrfHeader(headers, method, token != null); let result: Response; try { diff --git a/apps/server/src/context.rs b/apps/server/src/context.rs index 46b756807..2c8c56995 100644 --- a/apps/server/src/context.rs +++ b/apps/server/src/context.rs @@ -26,6 +26,7 @@ impl AppContext { static DEBUG: OnceCell = OnceCell::new(); static SECRET: OnceCell = OnceCell::new(); +static CSRF_COMPAT_MODE: OnceCell = OnceCell::new(); fn env_bool>(s: T) -> bool { let s = s.as_ref().trim(); @@ -40,6 +41,14 @@ pub fn debug() -> bool { *DEBUG.get_or_init(|| env::var("BOLUO_DEBUG").map(env_bool).unwrap_or(false)) } +pub fn csrf_compat_mode() -> bool { + *CSRF_COMPAT_MODE.get_or_init(|| { + env::var("BOLUO_CSRF_COMPAT_MODE") + .map(env_bool) + .unwrap_or(true) + }) +} + pub fn media_public_url() -> &'static str { static MEDIA_PUBLIC_URL: OnceCell = OnceCell::new(); MEDIA_PUBLIC_URL.get_or_init(|| { diff --git a/apps/server/src/csrf.rs b/apps/server/src/csrf.rs index 1bab331f4..9fda45813 100644 --- a/apps/server/src/csrf.rs +++ b/apps/server/src/csrf.rs @@ -1,51 +1,276 @@ use crate::error::AppError; use crate::session::{self, AuthenticateFail, Session}; -use crate::utils::sign; -use chrono::Utc; +use crate::utils::{sign, verify}; +use hyper::HeaderMap; use hyper::Request; use hyper::body::Body; +use hyper::header::{COOKIE, HeaderValue}; use uuid::Uuid; -// csrf-token:[session key(base 64)].[timestamp].[signature] +pub const CSRF_COOKIE_KEY: &str = "boluo-csrf-token"; +pub const CSRF_HEADER_KEY: &str = "x-csrf-token"; + +fn csrf_sign_message(session_key: &Uuid, nonce: &str) -> String { + let session_id = session_key.as_hyphenated().to_string(); + format!("{}!{}!{}!{}", session_id.len(), session_id, nonce.len(), nonce) +} + +fn is_safe_method(method: &hyper::Method) -> bool { + matches!( + *method, + hyper::Method::GET | hyper::Method::HEAD | hyper::Method::OPTIONS | hyper::Method::TRACE + ) +} + +fn parse_cookie(headers: &HeaderMap, key: &str) -> Option { + let cookies = headers.get_all(COOKIE); + for header_value in cookies.iter() { + let Ok(value) = header_value.to_str() else { + continue; + }; + for item in value.split(';') { + let item = item.trim(); + let Some((name, cookie_value)) = item.split_once('=') else { + continue; + }; + if name == key && !cookie_value.is_empty() { + return Some(cookie_value.to_string()); + } + } + } + None +} + +fn invalid_csrf() -> AppError { + AppError::NoPermission("Invalid CSRF token".to_string()) +} + +fn csrf_fail( + req: &Request, + session: &Session, + reason: &'static str, +) -> Result<(), AppError> { + let compat_mode = crate::context::csrf_compat_mode(); + tracing::warn!( + user_id = %session.user_id, + session_id = %session.id, + method = %req.method(), + path = %req.uri().path(), + reason = reason, + compat_mode = compat_mode, + "CSRF validation failed" + ); + + if compat_mode { + Ok(()) + } else { + Err(invalid_csrf()) + } +} + +fn verify_csrf(req: &Request, session: &Session) -> Result<(), AppError> { + if is_safe_method(req.method()) { + return Ok(()); + } + if !session::is_authenticate_use_cookie(req.headers()) { + return Ok(()); + } + + let Some(header_token) = req + .headers() + .get(CSRF_HEADER_KEY) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|token| !token.is_empty()) + else { + if crate::context::csrf_compat_mode() { + return Ok(()); + } + return csrf_fail(req, session, "missing_header"); + }; + + let Some(cookie_token) = parse_cookie(req.headers(), CSRF_COOKIE_KEY) else { + return csrf_fail(req, session, "missing_cookie"); + }; + + if header_token != cookie_token { + return csrf_fail(req, session, "header_cookie_mismatch"); + } + + let Some((nonce, signature)) = cookie_token.split_once('.') else { + return csrf_fail(req, session, "malformed_token"); + }; + if nonce.is_empty() || signature.is_empty() { + return csrf_fail(req, session, "empty_nonce_or_signature"); + } + + let message = csrf_sign_message(&session.id, nonce); + if verify(&message, signature).is_err() { + return csrf_fail(req, session, "invalid_signature"); + } + Ok(()) +} pub async fn authenticate(req: &Request) -> Result { let session = session::authenticate(req).await?; + verify_csrf(req, &session)?; Ok(session) } pub async fn authenticate_optional(req: &Request) -> Result, AppError> { let session = session::authenticate(req).await; match session { - Ok(session) => Ok(Some(session)), + Ok(session) => { + verify_csrf(req, &session)?; + Ok(Some(session)) + } Err(AppError::Unauthenticated(AuthenticateFail::Guest)) => Ok(None), Err(e) => Err(e), } } pub fn generate_csrf_token(session_key: &Uuid) -> String { - use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD as base64_engine}; + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as base64_engine}; + + // OWASP Signed Double-Submit Cookie: + // csrf = random_nonce + "." + HMAC(session_id + random_nonce) + let nonce = Uuid::new_v4().as_simple().to_string(); + let message = csrf_sign_message(session_key, &nonce); + let signature = sign(&message); + + let mut token = String::with_capacity(128); + token.push_str(&nonce); + token.push('.'); + base64_engine.encode_string(signature.as_ref(), &mut token); + token +} + +fn cookie_domain_from_origin(origin: Option<&str>) -> Option<&'static str> { + // TODO: do not hardcode the domain + let origin = origin?; + if origin.ends_with("boluochat.com") { + Some(".boluochat.com") + } else if origin.ends_with("boluo.chat") { + Some(".boluo.chat") + } else if origin.ends_with("boluo-staging.mythal.net") { + Some(".boluo-staging.mythal.net") + } else { + None + } +} + +pub fn add_csrf_cookie( + origin: Option<&str>, + token: &str, + is_debug: bool, + response_header: &mut HeaderMap, +) { + use cookie::time::Duration; + use cookie::{CookieBuilder, SameSite}; + use hyper::header::SET_COOKIE; - let expire_sec = 60 * 60 * 3; - let timestamp: i64 = Utc::now().timestamp() + expire_sec; - let mut buffer = String::with_capacity(128); - base64_engine.encode_string(session_key.as_bytes(), &mut buffer); - buffer.push('.'); - buffer.push_str(×tamp.to_string()); - let signature = sign(&buffer); - buffer.push('.'); - base64_engine.encode_string(signature, &mut buffer); - buffer + let mut builder = CookieBuilder::new(CSRF_COOKIE_KEY, token.to_string()) + .same_site(SameSite::Lax) + .secure(!is_debug) + .http_only(false) + .path("/") + .max_age(Duration::days(120)); + + if let Some(domain) = cookie_domain_from_origin(origin) { + builder = builder.domain(domain); + } + + let csrf_cookie = builder.build().to_string(); + response_header.append(SET_COOKIE, HeaderValue::from_str(&csrf_cookie).unwrap()); +} + +pub fn remove_csrf_cookie(headers: &mut HeaderMap) { + use cookie::CookieBuilder; + use cookie::time::Duration; + use hyper::header::SET_COOKIE; + use std::sync::OnceLock; + + static SET_COOKIE_LIST_CELL: OnceLock> = OnceLock::new(); + let set_cookie_list = SET_COOKIE_LIST_CELL.get_or_init(|| { + let zero = Duration::seconds(0); + let domain_list = [".boluo.chat", ".boluochat.com", ".boluo-staging.mythal.net"]; + let mut cookies: Vec = domain_list + .iter() + .map(|&domain| { + HeaderValue::from_str( + &CookieBuilder::new(CSRF_COOKIE_KEY, "") + .domain(domain) + .path("/") + .max_age(zero) + .build() + .to_string(), + ) + .unwrap() + }) + .collect(); + + cookies.push( + HeaderValue::from_str( + &CookieBuilder::new(CSRF_COOKIE_KEY, "") + .path("/") + .max_age(zero) + .build() + .to_string(), + ) + .unwrap(), + ); + cookies + }); + + for cookie in set_cookie_list { + headers.append(SET_COOKIE, cookie.clone()); + } } pub async fn get_csrf_token( _ctx: &crate::context::AppContext, req: Request, -) -> Result { +) -> Result>, AppError> { + let origin = req + .headers() + .get(hyper::header::ORIGIN) + .and_then(|x| x.to_str().ok()) + .map(|s| s.to_string()); + let is_debug = req.headers().get("X-Debug").is_some(); + let session_id = if let Ok(session) = session::authenticate(&req).await { session.id } else { Uuid::nil() }; + let token = generate_csrf_token(&session_id); + + let mut response = crate::interface::ok_response(token.clone()); + add_csrf_cookie(origin.as_deref(), &token, is_debug, response.headers_mut()); + Ok(response) +} - Ok(generate_csrf_token(&session_id)) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_csrf_token_unique() { + let session_id = Uuid::new_v4(); + let token_1 = generate_csrf_token(&session_id); + let token_2 = generate_csrf_token(&session_id); + assert_ne!(token_1, token_2); + } + + #[test] + fn test_generate_csrf_token_invalid_for_other_session() { + let session_a = Uuid::new_v4(); + let session_b = Uuid::new_v4(); + let token = generate_csrf_token(&session_a); + + let (nonce, signature) = token.split_once('.').expect("csrf token format"); + let message = csrf_sign_message(&session_b, nonce); + + assert!(verify(&message, signature).is_err()); + } } diff --git a/apps/server/src/server.rs b/apps/server/src/server.rs index e80bb65d4..bbc33090e 100644 --- a/apps/server/src/server.rs +++ b/apps/server/src/server.rs @@ -62,7 +62,7 @@ mod websocket; use crate::cors::allow_origin; use crate::db::MIGRATOR; use crate::error::AppError; -use crate::interface::{err_response, missing, ok_response}; +use crate::interface::{err_response, missing}; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -91,7 +91,7 @@ async fn router( }; } if path == "/api/csrf-token" { - return csrf::get_csrf_token(ctx, req).await.map(ok_response); + return csrf::get_csrf_token(ctx, req).await; } if path.starts_with("/api/tunnel") { return Ok(sentry_tunnel::handler(req).await); diff --git a/apps/server/src/users/handlers.rs b/apps/server/src/users/handlers.rs index 42294d197..c243c9aa6 100644 --- a/apps/server/src/users/handlers.rs +++ b/apps/server/src/users/handlers.rs @@ -6,6 +6,7 @@ use super::api::{ use super::models::User; use crate::channels::Channel; use crate::context::get_site_url; +use crate::csrf::{add_csrf_cookie, remove_csrf_cookie}; use crate::error::{AppError, Find, ValidationFailed}; use crate::interface::{self, response}; use crate::interface::{missing, ok_response, parse_body, parse_query}; @@ -236,6 +237,8 @@ pub async fn login( add_settings_cookie(origin.as_deref(), &settings, headers); if !form.with_token { add_session_cookie(origin.as_deref(), &session.id, is_debug, headers); + let csrf_token = crate::csrf::generate_csrf_token(&session.id); + add_csrf_cookie(origin.as_deref(), &csrf_token, is_debug, headers); } Ok(response) } @@ -251,6 +254,7 @@ pub async fn logout( } let mut response = ok_response(true); remove_session_cookie(response.headers_mut()); + remove_csrf_cookie(response.headers_mut()); Ok(response) } @@ -569,7 +573,7 @@ pub async fn resend_email_verification( ctx: &crate::context::AppContext, req: Request, ) -> Result { - use crate::session::authenticate; + use crate::csrf::authenticate; use crate::users::api::ResendEmailVerification; let session = authenticate(&req).await?; @@ -680,7 +684,7 @@ pub async fn request_email_change( ctx: &crate::context::AppContext, req: Request, ) -> Result<(), AppError> { - use crate::session::authenticate; + use crate::csrf::authenticate; use crate::users::api::RequestEmailChange; let session = authenticate(&req).await?; diff --git a/packages/api-browser/src/index.ts b/packages/api-browser/src/index.ts index 1e6209a55..fcda151f4 100644 --- a/packages/api-browser/src/index.ts +++ b/packages/api-browser/src/index.ts @@ -3,6 +3,7 @@ import { atom } from 'jotai'; import { store } from '@boluo/store'; import type { Result } from '@boluo/utils/result'; import { isCrossOrigin } from '@boluo/utils/browser'; +import { applyCsrfHeader } from '@boluo/utils/csrf'; import { type ApiError } from '@boluo/api/errors'; import type { LoginReturn, Media, User } from '@boluo/types/bindings'; import type { StringKeyOf } from '@boluo/types'; @@ -40,7 +41,6 @@ export const apiUrlAtom = atom((get) => { }); const TOKEN_KEY = 'BOLUO_TOKEN_V1'; - export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) || null; } @@ -58,6 +58,7 @@ function addToken(params: RequestInit): RequestInit { if (token) { headers.set('Authorization', token); } + applyCsrfHeader(headers, params.method, token != null); // headers.set('Authorization', `Bearer ${token}`); return { ...params, headers }; } diff --git a/packages/utils/package.json b/packages/utils/package.json index 802213e8e..645153f1f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -19,7 +19,8 @@ "private": true, "scripts": { "check": "tsc --noEmit", - "lint": "eslint . --ext .ts,.tsx" + "lint": "eslint . --ext .ts,.tsx", + "test": "node --import tsx --test src/**/*.test.ts" }, "sideEffects": false, "type": "module" diff --git a/packages/utils/src/csrf.test.ts b/packages/utils/src/csrf.test.ts new file mode 100644 index 000000000..2ba471862 --- /dev/null +++ b/packages/utils/src/csrf.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { readCookie } from './csrf.js'; + +describe('readCookie', () => { + test('returns null for empty cookie source', () => { + assert.strictEqual(readCookie('boluo-csrf-token', ''), null); + }); + + test('reads value from a single cookie entry', () => { + assert.strictEqual(readCookie('boluo-csrf-token', 'boluo-csrf-token=abc123'), 'abc123'); + }); + + test('reads target value from multiple cookie entries', () => { + const source = 'theme=dark; boluo-csrf-token=signed-token; locale=en'; + assert.strictEqual(readCookie('boluo-csrf-token', source), 'signed-token'); + }); + + test('keeps everything after first = in cookie value', () => { + const source = 'boluo-csrf-token=a=b=c; locale=en'; + assert.strictEqual(readCookie('boluo-csrf-token', source), 'a=b=c'); + }); + + test('returns null when target cookie is missing', () => { + const source = 'theme=dark; locale=en'; + assert.strictEqual(readCookie('boluo-csrf-token', source), null); + }); + + test('returns null when target cookie value is empty', () => { + const source = 'theme=dark; boluo-csrf-token=; locale=en'; + assert.strictEqual(readCookie('boluo-csrf-token', source), null); + }); + + test('does not match partial cookie names', () => { + const source = 'x-boluo-csrf-token=bad; boluo-csrf-token=good'; + assert.strictEqual(readCookie('boluo-csrf-token', source), 'good'); + }); +}); diff --git a/packages/utils/src/csrf.ts b/packages/utils/src/csrf.ts new file mode 100644 index 000000000..b38cd0ab2 --- /dev/null +++ b/packages/utils/src/csrf.ts @@ -0,0 +1,43 @@ +export const CSRF_COOKIE_KEY = 'boluo-csrf-token'; +export const CSRF_HEADER_KEY = 'X-CSRF-Token'; + +export function readCookie(name: string, cookieSource?: string): string | null { + const source = cookieSource ?? (typeof document === 'undefined' ? '' : document.cookie); + if (source === '') { + return null; + } + + for (const item of source.split(';')) { + const cookie = item.trim(); + const splitIndex = cookie.indexOf('='); + if (splitIndex <= 0) { + continue; + } + const cookieName = cookie.slice(0, splitIndex); + if (cookieName !== name) { + continue; + } + const value = cookie.slice(splitIndex + 1); + return value === '' ? null : value; + } + return null; +} + +export function isUnsafeHttpMethod(method: string | undefined): boolean { + const current = (method ?? 'GET').toUpperCase(); + return !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(current); +} + +export function applyCsrfHeader( + headers: Headers, + method: string | undefined, + hasAuthorizationToken: boolean, +): void { + if (hasAuthorizationToken || !isUnsafeHttpMethod(method)) { + return; + } + const token = readCookie(CSRF_COOKIE_KEY); + if (token) { + headers.set(CSRF_HEADER_KEY, token); + } +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index fb0decc28..49a906035 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@boluo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules", "dist"]