Skip to content

Commit ae3a0a0

Browse files
avrabeclaude
andauthored
fix: audit parser hardness — bounded sections, x509 depth, DSSE fuzz, mime-type (#98)
Closes 4 findings from the 2026-04-30 audit: H-1 — bound WASM SectionsIterator at MAX_SECTIONS=4096 H-2 — bound x509 chain depth at MAX_CHAIN_DEPTH=8 H-6 — PAYLOAD_TYPE_SLSA → application/vnd.slsa.provenance+json H-7 — add fuzz_dsse_envelope target with round-trip oracle Fixes: H-1, H-2, H-6, H-7 Verifies: CR-8 (bounded resource consumption) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fb120dd commit ae3a0a0

6 files changed

Lines changed: 186 additions & 3 deletions

File tree

fuzz/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,10 @@ path = "fuzz_targets/fuzz_format_detection.rs"
9494
test = false
9595
doc = false
9696
bench = false
97+
98+
[[bin]]
99+
name = "fuzz_dsse_envelope"
100+
path = "fuzz_targets/fuzz_dsse_envelope.rs"
101+
test = false
102+
doc = false
103+
bench = false
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! Fuzz target for DSSE envelope JSON parsing.
2+
//!
3+
//! `wsc::dsse::DsseEnvelope` is a central attestation parser: it accepts
4+
//! untrusted JSON whose `signatures` field is an unbounded `Vec<DsseSignature>`,
5+
//! and the envelope is consumed by every downstream verifier.
6+
//!
7+
//! Security concerns this target exercises:
8+
//! - JSON denial-of-service (deeply nested structures, oversize signatures).
9+
//! - serde_json error handling on malformed input.
10+
//! - Round-trip stability: parse → serialize → parse must yield equal
11+
//! structural data, otherwise an attacker may craft an envelope whose
12+
//! re-serialized form differs from the bytes that were actually verified.
13+
//!
14+
//! Oracle: not just "doesn't crash" — also a structural round-trip equality
15+
//! check on any successfully parsed envelope.
16+
17+
#![no_main]
18+
19+
use libfuzzer_sys::fuzz_target;
20+
use wsc::dsse::DsseEnvelope;
21+
22+
fuzz_target!(|data: &[u8]| {
23+
// Treat input as candidate UTF-8 JSON. Skip non-UTF-8 inputs early so
24+
// the deserializer is not asked to do work on bytes that can never be
25+
// valid JSON (serde_json would reject them anyway, but this keeps
26+
// corpus mutations focused on JSON-shaped inputs).
27+
let s = match std::str::from_utf8(data) {
28+
Ok(s) => s,
29+
Err(_) => return,
30+
};
31+
32+
let envelope = match DsseEnvelope::from_json(s) {
33+
Ok(e) => e,
34+
Err(_) => return,
35+
};
36+
37+
// Round-trip oracle: serialize back to JSON, parse again, and assert
38+
// that the two parsed envelopes are structurally identical. A divergence
39+
// here would indicate a serde quirk an attacker could exploit (e.g. a
40+
// field that survives the first parse but is dropped on the second).
41+
let json = envelope
42+
.to_json()
43+
.expect("serialization of a successfully parsed envelope must succeed");
44+
45+
let envelope2 = DsseEnvelope::from_json(&json)
46+
.expect("re-parse of self-serialized envelope must succeed");
47+
48+
assert_eq!(envelope.payload, envelope2.payload);
49+
assert_eq!(envelope.payload_type, envelope2.payload_type);
50+
assert_eq!(envelope.signatures.len(), envelope2.signatures.len());
51+
for (a, b) in envelope.signatures.iter().zip(envelope2.signatures.iter()) {
52+
assert_eq!(a.keyid, b.keyid);
53+
assert_eq!(a.sig, b.sig);
54+
}
55+
});

src/attestation/src/dsse.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize};
2626
pub const PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";
2727

2828
/// DSSE payload type for SLSA provenance
29-
pub const PAYLOAD_TYPE_SLSA: &str = "application/vnd.in-toto+json";
29+
pub const PAYLOAD_TYPE_SLSA: &str = "application/vnd.slsa.provenance+json";
3030

3131
/// Dead Simple Signing Envelope
3232
///

src/lib/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ pub enum WSError {
6464
#[error("Too many certificates (max: {0})")]
6565
TooManyCertificates(usize),
6666

67+
#[error("Too many sections (max: {0})")]
68+
TooManySections(usize),
69+
70+
#[error("Certificate chain too deep (max: {0})")]
71+
ChainTooDeep(usize),
72+
6773
#[error("Usage error: {0}")]
6874
UsageError(&'static str),
6975

src/lib/src/signature/keyless/format.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ pub const KEYLESS_SIG_TYPE: u8 = 0x02;
1818
/// Standard signature type identifier
1919
pub const STANDARD_SIG_TYPE: u8 = 0x01;
2020

21+
/// Maximum accepted depth of an embedded X.509 certificate chain.
22+
///
23+
/// Real-world Fulcio chains are length 2–3 (leaf + intermediate(s) + root).
24+
/// Industry CAs ship at most 4–5. We cap at 8 — generous headroom while
25+
/// rejecting adversarial 1000-cert chains that would trigger heap exhaustion
26+
/// in `x509_parser` / WebPKI before any signature work begins.
27+
pub const MAX_CHAIN_DEPTH: usize = 8;
28+
2129
/// Keyless signature custom section format
2230
///
2331
/// Binary format (extends existing wasmsig format):
@@ -367,6 +375,13 @@ impl KeylessSignature {
367375
));
368376
}
369377

378+
// SECURITY: bound chain depth before invoking x509_parser/WebPKI.
379+
// An adversarial 1000-cert chain would otherwise trigger heap
380+
// exhaustion during PEM/DER decoding.
381+
if self.cert_chain.len() > MAX_CHAIN_DEPTH {
382+
return Err(WSError::ChainTooDeep(MAX_CHAIN_DEPTH));
383+
}
384+
370385
// Load Fulcio trusted roots
371386
let cert_pool = CertificatePool::from_embedded_trust_root().map_err(|e| {
372387
WSError::CertificateError(format!("Failed to load trusted roots: {}", e))
@@ -695,6 +710,50 @@ mod tests {
695710
assert_eq!(deserialized.signature, sig.signature);
696711
}
697712

713+
#[test]
714+
fn test_verify_cert_chain_rejects_too_deep() {
715+
// A 100-cert synthetic chain must be rejected before any x509 parsing.
716+
// This exercises the MAX_CHAIN_DEPTH guard in verify_cert_chain.
717+
let mut sig = create_test_signature();
718+
sig.cert_chain = (0..100)
719+
.map(|i| {
720+
format!(
721+
"-----BEGIN CERTIFICATE-----\nfake-cert-{}\n-----END CERTIFICATE-----",
722+
i
723+
)
724+
})
725+
.collect();
726+
727+
let result = sig.verify_cert_chain();
728+
match result {
729+
Err(WSError::ChainTooDeep(max)) => assert_eq!(max, MAX_CHAIN_DEPTH),
730+
Err(other) => panic!("expected ChainTooDeep, got {:?}", other),
731+
Ok(_) => panic!("expected ChainTooDeep, got Ok"),
732+
}
733+
}
734+
735+
#[test]
736+
fn test_verify_cert_chain_at_max_depth_proceeds_to_parser() {
737+
// A chain of MAX_CHAIN_DEPTH bogus PEMs must NOT be rejected by the
738+
// depth check; it should fall through to PEM/X.509 parsing and fail
739+
// there. This proves the bound is at MAX_CHAIN_DEPTH+1, not below.
740+
let mut sig = create_test_signature();
741+
sig.cert_chain = (0..MAX_CHAIN_DEPTH)
742+
.map(|i| {
743+
format!(
744+
"-----BEGIN CERTIFICATE-----\nfake-cert-{}\n-----END CERTIFICATE-----",
745+
i
746+
)
747+
})
748+
.collect();
749+
750+
let result = sig.verify_cert_chain();
751+
// Must not be rejected by depth guard
752+
assert!(!matches!(result, Err(WSError::ChainTooDeep(_))));
753+
// But it must still fail (these aren't real Fulcio certs)
754+
assert!(result.is_err());
755+
}
756+
698757
#[test]
699758
fn test_large_module_hash() {
700759
let mut sig = create_test_signature();

src/lib/src/wasm_module/mod.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ const WASM_HEADER: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
2424
const WASM_COMPONENT_HEADER: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00];
2525
pub type Header = [u8; 8];
2626

27+
/// Maximum number of sections accepted by `SectionsIterator` before the parser
28+
/// aborts with `WSError::TooManySections`. 4096 is generous for any legitimate
29+
/// module (the wasm-tools spec recommends ~100 typical sections; the Component
30+
/// Model adds a handful more) while bounding worst-case work for adversarial
31+
/// inputs that declare millions of empty sections.
32+
pub const MAX_SECTIONS: usize = 4096;
33+
2734
/// A section identifier.
2835
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
2936
#[repr(u8)]
@@ -452,14 +459,17 @@ impl Module {
452459
Ok(ModuleStreamReader { reader, header })
453460
}
454461

455-
/// Return an iterator over the sections of a WebAssembly module.
462+
/// Return an iterator over the sections of a WebAssembly module.
456463
///
457464
/// The module is read in a streaming fashion, and doesn't have to be fully loaded into memory.
465+
/// The iterator caps total emitted sections at [`MAX_SECTIONS`] to prevent
466+
/// adversarial modules from causing unbounded work.
458467
pub fn iterate<T: Read>(
459468
module_stream: ModuleStreamReader<T>,
460469
) -> Result<SectionsIterator<T>, WSError> {
461470
Ok(SectionsIterator {
462471
reader: module_stream.reader,
472+
count: 0,
463473
})
464474
}
465475
}
@@ -470,18 +480,31 @@ pub struct ModuleStreamReader<'t, T: Read> {
470480
}
471481

472482
/// An iterator over the sections of a WebAssembly module.
483+
///
484+
/// Yields at most [`MAX_SECTIONS`] sections; the next call after the cap is
485+
/// reached returns `Some(Err(WSError::TooManySections(MAX_SECTIONS)))` and the
486+
/// iterator subsequently terminates.
473487
pub struct SectionsIterator<'t, T: Read> {
474488
reader: &'t mut T,
489+
count: usize,
475490
}
476491

477492
impl<'t, T: Read> Iterator for SectionsIterator<'t, T> {
478493
type Item = Result<Section, WSError>;
479494

480495
fn next(&mut self) -> Option<Self::Item> {
496+
if self.count >= MAX_SECTIONS {
497+
// Bound iteration so a malformed module declaring millions of
498+
// empty sections cannot loop the parser indefinitely.
499+
return Some(Err(WSError::TooManySections(MAX_SECTIONS)));
500+
}
481501
match Section::deserialize(self.reader) {
482502
Err(e) => Some(Err(e)),
483503
Ok(None) => None,
484-
Ok(Some(section)) => Some(Ok(section)),
504+
Ok(Some(section)) => {
505+
self.count += 1;
506+
Some(Ok(section))
507+
}
485508
}
486509
}
487510
}
@@ -965,6 +988,39 @@ mod tests {
965988
"tampered component must fail verification"
966989
);
967990
}
991+
992+
#[test]
993+
fn test_sections_iterator_max_sections_cap() {
994+
// Construct a WASM module: header + (MAX_SECTIONS + 1) empty Type sections.
995+
// Each empty section is two bytes: id=1 (Type), len=0.
996+
// The iterator must reject once it has yielded MAX_SECTIONS sections.
997+
let mut bytes = Vec::with_capacity(8 + 2 * (MAX_SECTIONS + 1));
998+
bytes.extend_from_slice(&WASM_HEADER);
999+
for _ in 0..(MAX_SECTIONS + 1) {
1000+
bytes.push(0x01); // SectionId::Type
1001+
bytes.push(0x00); // payload length 0
1002+
}
1003+
1004+
let mut reader = io::Cursor::new(&bytes);
1005+
let stream = Module::init_from_reader(&mut reader).expect("header parses");
1006+
let it = Module::iterate(stream).expect("iterator constructs");
1007+
1008+
let mut seen = 0usize;
1009+
let mut hit_cap = false;
1010+
for item in it {
1011+
match item {
1012+
Ok(_) => seen += 1,
1013+
Err(WSError::TooManySections(max)) => {
1014+
assert_eq!(max, MAX_SECTIONS);
1015+
hit_cap = true;
1016+
break;
1017+
}
1018+
Err(e) => panic!("unexpected error before cap: {:?}", e),
1019+
}
1020+
}
1021+
assert_eq!(seen, MAX_SECTIONS, "should yield exactly MAX_SECTIONS first");
1022+
assert!(hit_cap, "iterator must error with TooManySections after the cap");
1023+
}
9681024
}
9691025

9701026
// ============================================================================

0 commit comments

Comments
 (0)