diff --git a/src/runtime/api/csrf_jsc.rs b/src/runtime/api/csrf_jsc.rs index ce3fcca122b3..7f9482525a44 100644 --- a/src/runtime/api/csrf_jsc.rs +++ b/src/runtime/api/csrf_jsc.rs @@ -21,33 +21,6 @@ fn algorithm_from_js_case_insensitive( Ok(evp::lookup_ignore_case(slice.slice())) } -/// Validates an optional integer property in `[0, MAX_SAFE_INTEGER]`. -/// Differs from `JSValue::get_optional_int::` in rejecting NaN and in -/// the error message wording expected by existing tests. -fn get_optional_int_u64( - target: JSValue, - global: &JSGlobalObject, - property: &'static str, -) -> JsResult> { - let Some(value) = target.get(global, property)? else { - return Ok(None); - }; - if value.is_undefined() || value.is_empty() { - return Ok(Some(0)); - } - if !value.is_number() { - return Err(global.throw_invalid_argument_type_value(property, "number", value)); - } - let num: f64 = value.as_number(); - const MAX_SAFE_INTEGER: f64 = 9007199254740991.0; - if num.fract() != 0.0 || num < 0.0 || num > MAX_SAFE_INTEGER { - return Err(global.throw_invalid_arguments(format_args!( - "{property} must be an integer between 0 and {MAX_SAFE_INTEGER}" - ))); - } - Ok(Some(num as u64)) -} - /// JS binding function for generating CSRF tokens /// First argument is secret (required), second is options (optional) #[bun_jsc::host_fn] @@ -81,7 +54,7 @@ pub(crate) fn csrf__generate(global: &JSGlobalObject, frame: &CallFrame) -> JsRe let options_value = args[1]; // Extract expiresIn (optional) - if let Some(expires_in_js) = get_optional_int_u64(options_value, global, "expiresIn")? { + if let Some(expires_in_js) = options_value.get_optional_int::(global, "expiresIn")? { expires_in = expires_in_js; } @@ -246,7 +219,7 @@ pub(crate) fn csrf__verify(global: &JSGlobalObject, frame: &CallFrame) -> JsResu } // Extract maxAge (optional) - if let Some(max_age_js) = get_optional_int_u64(options_value, global, "maxAge")? { + if let Some(max_age_js) = options_value.get_optional_int::(global, "maxAge")? { max_age = max_age_js; } diff --git a/test/js/bun/util/csrf.test.ts b/test/js/bun/util/csrf.test.ts index fedc1279c25d..1cfd7aa2f10f 100644 --- a/test/js/bun/util/csrf.test.ts +++ b/test/js/bun/util/csrf.test.ts @@ -160,6 +160,16 @@ describe("Bun.CSRF", () => { ); }); + test("expiresIn and maxAge accept NaN as 0 (no expiry)", () => { + // NaN falls through to the integer validator's fallback (0 = no expiry), + // matching node's integer validators. Note: this differs from omitting the + // option entirely, which defaults to 24h. + const token = CSRF.generate(secret, { expiresIn: NaN }); + expect(typeof token).toBe("string"); + expect(token.length).toBeGreaterThan(0); + expect(CSRF.verify(token, { secret, maxAge: NaN })).toBe(true); + }); + test("error handling", () => { // Empty token expect(() => CSRF.verify("", { secret })).toThrow(); @@ -182,6 +192,28 @@ describe("Bun.CSRF", () => { expect(() => CSRF.generate(secret, { sessionId: 123 })).toThrow(); // @ts-expect-error - testing invalid input expect(() => CSRF.verify(token, { secret, sessionId: 123 })).toThrow(); + + // expiresIn / maxAge validation + expect(() => CSRF.generate(secret, { expiresIn: -1 })).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + expect(() => CSRF.generate(secret, { expiresIn: 1.5 })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + // @ts-expect-error - testing invalid input + expect(() => CSRF.generate(secret, { expiresIn: "foo" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => CSRF.verify(token, { secret, maxAge: -1 })).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + expect(() => CSRF.verify(token, { secret, maxAge: 1.5 })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + // @ts-expect-error - testing invalid input + expect(() => CSRF.verify(token, { secret, maxAge: "foo" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); }); test("handle bad decoding", () => {