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):
- 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)

View file

@ -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,
});
}

View file

@ -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<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
pub struct AuthUser(
pub String, // user_id
@ -55,7 +71,7 @@ impl FromRequestParts<Arc<Env>> for Claims {
// Decode and validate the token
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()))?;
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 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<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('/');
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<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('/');
Ok(format!(
"{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 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::<RefreshClaims>(
&refresh_token,
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
&Validation::default(),
&jwt_validation(),
)
.map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;