diff --git a/backend/tests/app_guest_jwt_entry.rs b/backend/tests/app_guest_jwt_entry.rs index b114a4b87b..84664fb772 100644 --- a/backend/tests/app_guest_jwt_entry.rs +++ b/backend/tests/app_guest_jwt_entry.rs @@ -305,6 +305,9 @@ async fn guest_jwt_refusals(db: Pool) -> anyhow::Result<()> { c.app_path = "u/test-user/*".to_string(); let wildcard_app_path = bearer(&c, PRIV1, Algorithm::ES256); + // a token past the length cap is refused before any signature work or caching. + let oversized_token = format!("jwt_guest_{}", "a".repeat(9000)); + for (label, token) in [ ("wrong workspace", wrong_ws), ("wrong key", wrong_key), @@ -317,6 +320,7 @@ async fn guest_jwt_refusals(db: Pool) -> anyhow::Result<()> { ("group-shaped email", group_shaped_email), ("oversized email", oversized_email), ("wildcard app_path", wildcard_app_path), + ("oversized token", oversized_token), ] { let resp = whoami(port, ws, &token).send().await?; assert_eq!(resp.status(), 401, "{label} must be refused"); diff --git a/backend/windmill-common/src/guest_jwt.rs b/backend/windmill-common/src/guest_jwt.rs index 9c03fb3940..49496ff7db 100644 --- a/backend/windmill-common/src/guest_jwt.rs +++ b/backend/windmill-common/src/guest_jwt.rs @@ -261,8 +261,9 @@ const JWKS_MAX_RETAINED_BYTES: usize = 64 * 1024; const JWKS_MAX_KEYS: usize = 50; /// Both JWKS caches key on the admin-supplied URL string. The column is unbounded `TEXT`, so -/// without this a workspace admin rotating long URLs could grow the caches by the URL bytes -/// alone. A real JWKS URL is well under this; the check runs before the URL is ever cached. +/// without this a workspace admin could grow the caches by the URL bytes alone. Enforced in +/// `fetch_jwks`, which `edit_guest_jwt_key` validates through, so an overlong URL is never +/// stored; the cache only ever sees a URL that was stored, hence a bounded one. const MAX_JWKS_URL_LEN: usize = 2048; /// A guest verification key is admin-set into an unbounded `TEXT` column and reparsed on every @@ -270,6 +271,11 @@ const MAX_JWKS_URL_LEN: usize = 2048; /// this bounds the stored and reparsed bytes without refusing any real key. const MAX_GUEST_PEM_LEN: usize = 8 * 1024; +/// A guest JWT is refused past this before any signature work or caching: the auth cache keys +/// on the bearer, so an oversized token (unauthenticated at this point) would otherwise be +/// decoded and, if it verified, cached at its full size. A real JWT is well under this. +const MAX_GUEST_JWT_LEN: usize = 8 * 1024; + /// Fetch a JWKS, keeping only the keys usable here. The URL was set by a workspace /// admin, so it is validated against private ranges and the connect is pinned to the /// validated addresses; redirects are not followed for the same reason. The body is @@ -483,6 +489,11 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec Result { + if token.len() > MAX_GUEST_JWT_LEN { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: token is longer than {MAX_GUEST_JWT_LEN} bytes" + ))); + } let Some(source) = key_source(db, w_id).await? else { return Err(Error::NotAuthorized(format!( "guest JWT refused: workspace {w_id} has no guest JWT key"