Skip to content

Commit 2a5cb2e

Browse files
committed
fix(js): cap PBKDF2 iterations and output length to prevent runtime DoS
crypto.subtle.deriveBits/deriveKey for PBKDF2 passed the iteration count and output length from page JS straight into op_subtle_pbkdf2 with no bound. Because the JS runtime is single-threaded and shared across the CDP connection, a page calling deriveBits with iterations=4294967295 pins the V8 isolate for hours and blocks every other command; a huge requested length forces an unbounded vec![0u8; length] allocation. Split the derivation into a testable pbkdf2_derive helper that rejects iteration counts above 10_000_000 and output lengths above 1 MiB — both far above any legitimate use (OWASP recommends ~600k iterations; derived keys are tens of bytes) — with an OperationError before doing the work. Closes #580
1 parent 97124ed commit 2a5cb2e

1 file changed

Lines changed: 72 additions & 7 deletions

File tree

crates/obscura-js/src/ops.rs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2765,6 +2765,40 @@ mod tests {
27652765
#[cfg(feature = "render")]
27662766
use obscura_dom::ShadowRootMode;
27672767

2768+
use super::{pbkdf2_derive, PBKDF2_MAX_ITERATIONS, PBKDF2_MAX_OUTPUT_BYTES};
2769+
2770+
// SEC-006 / #580 — PBKDF2 parameters arrive straight from page JS. Without
2771+
// caps, a huge iteration count pins the single-threaded runtime and a huge
2772+
// output length forces an unbounded allocation. The derivation must reject
2773+
// both above the fixed maximums, and still work for ordinary inputs.
2774+
2775+
#[test]
2776+
fn pbkdf2_rejects_excessive_iterations() {
2777+
let err = pbkdf2_derive("SHA-256", b"pw", b"salt", PBKDF2_MAX_ITERATIONS + 1, 32)
2778+
.expect_err("iteration count above the cap must be rejected");
2779+
assert!(
2780+
err.to_string().contains("iteration"),
2781+
"error should name the iteration cap: {err}"
2782+
);
2783+
}
2784+
2785+
#[test]
2786+
fn pbkdf2_rejects_excessive_output_length() {
2787+
let err = pbkdf2_derive("SHA-256", b"pw", b"salt", 1_000, PBKDF2_MAX_OUTPUT_BYTES + 1)
2788+
.expect_err("output length above the cap must be rejected");
2789+
assert!(
2790+
err.to_string().contains("length"),
2791+
"error should name the length cap: {err}"
2792+
);
2793+
}
2794+
2795+
#[test]
2796+
fn pbkdf2_derives_within_limits() {
2797+
let dk = pbkdf2_derive("SHA-256", b"password", b"salt", 1_000, 32)
2798+
.expect("ordinary parameters must derive successfully");
2799+
assert_eq!(dk.len(), 32, "derived key must have the requested length");
2800+
}
2801+
27682802
#[test]
27692803
fn glob_match_handles_cdp_blocked_url_patterns() {
27702804
assert!(glob_match(
@@ -3527,16 +3561,34 @@ fn op_subtle_aes_ctr(
35273561
Ok(buf)
35283562
}
35293563

3530-
/// PBKDF2 key derivation. `length` is the derived-bits output in bytes.
3531-
#[op2]
3532-
#[buffer]
3533-
fn op_subtle_pbkdf2(
3534-
#[string] hash: &str,
3535-
#[buffer] password: &[u8],
3536-
#[buffer] salt: &[u8],
3564+
/// Generous upper bounds on PBKDF2 parameters. WebCrypto imposes no limit, but
3565+
/// page JS drives this op on the single-threaded runtime: an unbounded
3566+
/// iteration count pins the V8 isolate (blocking every other CDP command on the
3567+
/// connection) and a huge output length forces an unbounded `vec![0u8; length]`
3568+
/// allocation. Both caps sit far above any legitimate use — OWASP recommends
3569+
/// ~600k iterations and derived keys are tens of bytes.
3570+
const PBKDF2_MAX_ITERATIONS: u32 = 10_000_000;
3571+
const PBKDF2_MAX_OUTPUT_BYTES: u32 = 1024 * 1024;
3572+
3573+
/// PBKDF2 key derivation with DoS guards. Split out from the op so the bounds
3574+
/// are unit-testable without the `#[op2]` wrapper.
3575+
fn pbkdf2_derive(
3576+
hash: &str,
3577+
password: &[u8],
3578+
salt: &[u8],
35373579
iterations: u32,
35383580
length: u32,
35393581
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
3582+
if iterations > PBKDF2_MAX_ITERATIONS {
3583+
return Err(crypto_err(format!(
3584+
"PBKDF2 iteration count {iterations} exceeds the supported maximum of {PBKDF2_MAX_ITERATIONS}"
3585+
)));
3586+
}
3587+
if length > PBKDF2_MAX_OUTPUT_BYTES {
3588+
return Err(crypto_err(format!(
3589+
"PBKDF2 output length {length} bytes exceeds the supported maximum of {PBKDF2_MAX_OUTPUT_BYTES}"
3590+
)));
3591+
}
35403592
use pbkdf2::pbkdf2_hmac;
35413593
let mut dk = vec![0u8; length as usize];
35423594
match hash {
@@ -3549,6 +3601,19 @@ fn op_subtle_pbkdf2(
35493601
Ok(dk)
35503602
}
35513603

3604+
/// PBKDF2 key derivation. `length` is the derived-bits output in bytes.
3605+
#[op2]
3606+
#[buffer]
3607+
fn op_subtle_pbkdf2(
3608+
#[string] hash: &str,
3609+
#[buffer] password: &[u8],
3610+
#[buffer] salt: &[u8],
3611+
iterations: u32,
3612+
length: u32,
3613+
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
3614+
pbkdf2_derive(hash, password, salt, iterations, length)
3615+
}
3616+
35523617
/// HKDF key derivation. `length` is the output length in bytes. An empty salt
35533618
/// behaves as RFC 5869 specifies (HMAC zero-pads it to the block size, which is
35543619
/// what browsers do).

0 commit comments

Comments
 (0)