diff --git a/README.md b/README.md index 155e6ec..f311c8f 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Configure environment variables in `wrangler.toml` under `[vars]`, or set them v * **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional): - Max total attachment storage per user in KB. - Example: `1048576` for 1GB. -* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`): +* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`, Minimum: `60`): - TTL for attachment upload/download URLs. ### Scheduled Tasks (Cron) diff --git a/src/attachments.js b/src/attachments.js index f03a4a8..6053ab2 100644 --- a/src/attachments.js +++ b/src/attachments.js @@ -9,6 +9,8 @@ * Route matching and URL parsing should be handled by `src/entry.js`. */ +const JWT_EXPECTED_ALG = "HS256"; +const JWT_VALIDATION_LEEWAY_SECS = 60; // JWT validation using Web Crypto API (no external dependencies) async function verifyJwt(token, secret) { const encoder = new TextEncoder(); @@ -19,6 +21,17 @@ async function verifyJwt(token, secret) { const [headerB64, payloadB64, signatureB64] = parts; + let header; + try { + header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))); + } catch { + throw new Error("Invalid token header"); + } + + if (!header || typeof header !== "object" || header.alg !== JWT_EXPECTED_ALG) { + throw new Error("Invalid token algorithm"); + } + // Import the secret key for HMAC-SHA256 const key = await crypto.subtle.importKey( "raw", @@ -40,12 +53,23 @@ async function verifyJwt(token, secret) { } // Decode and parse the payload - const payload = JSON.parse( - new TextDecoder().decode(base64UrlDecode(payloadB64)) - ); + let payload; + try { + payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + } catch { + throw new Error("Invalid token payload"); + } - // Check expiration - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid token payload"); + } + + if (typeof payload.exp !== "number") { + throw new Error("Invalid token exp"); + } + + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now - JWT_VALIDATION_LEEWAY_SECS) { throw new Error("Token expired"); } @@ -442,5 +466,3 @@ export async function handleDownload(request, env, cipherId, attachmentId, token headers, }); } - - diff --git a/src/auth.rs b/src/auth.rs index b25f3dd..075d630 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -3,14 +3,17 @@ use axum::{ http::{header, request::Parts}, }; use constant_time_eq::constant_time_eq; -use jsonwebtoken::{decode, DecodingKey, Validation}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::sync::Arc; use worker::Env; use crate::db; use crate::error::AppError; +pub(crate) const JWT_VALIDATION_LEEWAY_SECS: u64 = 60; + #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: String, // User ID @@ -25,6 +28,19 @@ pub struct Claims { pub amr: Vec, } +pub(crate) fn jwt_validation() -> Validation { + let mut required_spec_claims = HashSet::new(); + required_spec_claims.insert("exp".to_string()); + + let mut validation = Validation::new(Algorithm::HS256); + validation.required_spec_claims = required_spec_claims; + validation.leeway = JWT_VALIDATION_LEEWAY_SECS; + validation.validate_exp = true; + validation.validate_nbf = true; + validation.algorithms = vec![Algorithm::HS256]; + validation +} + /// AuthUser extractor - provides (user_id, email) tuple pub struct AuthUser( pub String, // user_id @@ -55,7 +71,7 @@ impl FromRequestParts> for Claims { // Decode and validate the token let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref()); - let token_data = decode::(&token, &decoding_key, &Validation::default()) + let token_data = decode::(&token, &decoding_key, &jwt_validation()) .map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?; let claims = token_data.claims; diff --git a/src/handlers/attachments.rs b/src/handlers/attachments.rs index 59a450a..cf7af19 100644 --- a/src/handlers/attachments.rs +++ b/src/handlers/attachments.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata}; use crate::{ - auth::Claims, + auth::{Claims, JWT_VALIDATION_LEEWAY_SECS}, db, error::AppError, models::{ @@ -560,7 +560,7 @@ fn download_url( attachment_id: &str, user_id: &str, ) -> Result { - let token = build_download_token(env, user_id, cipher_id, attachment_id)?; + let token = build_upload_download_token(env, user_id, cipher_id, attachment_id)?; let normalized_base = base_url.trim_end_matches('/'); Ok(format!( "{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}" @@ -734,7 +734,7 @@ fn validate_size_within_declared( Ok(()) } -fn build_download_token( +fn build_upload_download_token( env: &Env, user_id: &str, cipher_id: &str, @@ -744,6 +744,7 @@ fn build_download_token( let now = Utc::now().timestamp(); let exp = now .checked_add(ttl_secs) + .and_then(|exp| exp.checked_sub(JWT_VALIDATION_LEEWAY_SECS as i64)) .ok_or_else(|| AppError::Internal)?; if exp < 0 { @@ -778,7 +779,7 @@ fn upload_url( attachment_id: &str, user_id: &str, ) -> Result { - let token = build_download_token(env, user_id, cipher_id, attachment_id)?; + let token = build_upload_download_token(env, user_id, cipher_id, attachment_id)?; let normalized_base = base_url.trim_end_matches('/'); Ok(format!( "{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}" diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index eb2db90..0124a66 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -1,14 +1,14 @@ use axum::{extract::State, Form, Json}; use chrono::{Duration, Utc}; use constant_time_eq::constant_time_eq; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::sync::Arc; use worker::{query, Env}; use crate::{ - auth::Claims, + auth::{jwt_validation, Claims}, crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, db, error::AppError, @@ -494,7 +494,7 @@ pub async fn token( let token_data = decode::( &refresh_token, &DecodingKey::from_secret(jwt_refresh_secret.as_ref()), - &Validation::default(), + &jwt_validation(), ) .map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;