chore: pin JWT validation parameters

This commit is contained in:
qaz741wsd856 2025-12-31 02:30:28 +00:00
parent bcfd15ee91
commit 062cec5dfe
5 changed files with 56 additions and 17 deletions

View file

@ -186,7 +186,7 @@ Configure environment variables in `wrangler.toml` under `[vars]`, or set them v
* **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional): * **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional):
- Max total attachment storage per user in KB. - Max total attachment storage per user in KB.
- Example: `1048576` for 1GB. - Example: `1048576` for 1GB.
* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`): * **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`, Minimum: `60`):
- TTL for attachment upload/download URLs. - TTL for attachment upload/download URLs.
### Scheduled Tasks (Cron) ### Scheduled Tasks (Cron)

View file

@ -9,6 +9,8 @@
* Route matching and URL parsing should be handled by `src/entry.js`. * 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) // JWT validation using Web Crypto API (no external dependencies)
async function verifyJwt(token, secret) { async function verifyJwt(token, secret) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@ -19,6 +21,17 @@ async function verifyJwt(token, secret) {
const [headerB64, payloadB64, signatureB64] = parts; 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 // Import the secret key for HMAC-SHA256
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
"raw", "raw",
@ -40,12 +53,23 @@ async function verifyJwt(token, secret) {
} }
// Decode and parse the payload // Decode and parse the payload
const payload = JSON.parse( let payload;
new TextDecoder().decode(base64UrlDecode(payloadB64)) try {
); payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
} catch {
throw new Error("Invalid token payload");
}
// Check expiration if (!payload || typeof payload !== "object") {
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { 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"); throw new Error("Token expired");
} }
@ -442,5 +466,3 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
headers, headers,
}); });
} }

View file

@ -3,14 +3,17 @@ use axum::{
http::{header, request::Parts}, http::{header, request::Parts},
}; };
use constant_time_eq::constant_time_eq; use constant_time_eq::constant_time_eq;
use jsonwebtoken::{decode, DecodingKey, Validation}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use worker::Env; use worker::Env;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
pub(crate) const JWT_VALIDATION_LEEWAY_SECS: u64 = 60;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
pub sub: String, // User ID pub sub: String, // User ID
@ -25,6 +28,19 @@ pub struct Claims {
pub amr: Vec<String>, pub amr: Vec<String>,
} }
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 /// AuthUser extractor - provides (user_id, email) tuple
pub struct AuthUser( pub struct AuthUser(
pub String, // user_id pub String, // user_id
@ -55,7 +71,7 @@ impl FromRequestParts<Arc<Env>> for Claims {
// Decode and validate the token // Decode and validate the token
let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref()); let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref());
let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default()) let token_data = decode::<Claims>(&token, &decoding_key, &jwt_validation())
.map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?; .map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?;
let claims = token_data.claims; let claims = token_data.claims;

View file

@ -14,7 +14,7 @@ use uuid::Uuid;
use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata}; use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata};
use crate::{ use crate::{
auth::Claims, auth::{Claims, JWT_VALIDATION_LEEWAY_SECS},
db, db,
error::AppError, error::AppError,
models::{ models::{
@ -560,7 +560,7 @@ fn download_url(
attachment_id: &str, attachment_id: &str,
user_id: &str, user_id: &str,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
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('/'); let normalized_base = base_url.trim_end_matches('/');
Ok(format!( Ok(format!(
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}" "{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}"
@ -734,7 +734,7 @@ fn validate_size_within_declared(
Ok(()) Ok(())
} }
fn build_download_token( fn build_upload_download_token(
env: &Env, env: &Env,
user_id: &str, user_id: &str,
cipher_id: &str, cipher_id: &str,
@ -744,6 +744,7 @@ fn build_download_token(
let now = Utc::now().timestamp(); let now = Utc::now().timestamp();
let exp = now let exp = now
.checked_add(ttl_secs) .checked_add(ttl_secs)
.and_then(|exp| exp.checked_sub(JWT_VALIDATION_LEEWAY_SECS as i64))
.ok_or_else(|| AppError::Internal)?; .ok_or_else(|| AppError::Internal)?;
if exp < 0 { if exp < 0 {
@ -778,7 +779,7 @@ fn upload_url(
attachment_id: &str, attachment_id: &str,
user_id: &str, user_id: &str,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
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('/'); let normalized_base = base_url.trim_end_matches('/');
Ok(format!( Ok(format!(
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}" "{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}"

View file

@ -1,14 +1,14 @@
use axum::{extract::State, Form, Json}; use axum::{extract::State, Form, Json};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use constant_time_eq::constant_time_eq; 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::{Deserialize, Deserializer, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use worker::{query, Env}; use worker::{query, Env};
use crate::{ use crate::{
auth::Claims, auth::{jwt_validation, Claims},
crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp},
db, db,
error::AppError, error::AppError,
@ -494,7 +494,7 @@ pub async fn token(
let token_data = decode::<RefreshClaims>( let token_data = decode::<RefreshClaims>(
&refresh_token, &refresh_token,
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()), &DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
&Validation::default(), &jwt_validation(),
) )
.map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?; .map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;