Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Copyright 2026 ExtendDB contributors
# SPDX-License-Identifier: Apache-2.0
#
# Fuzz the request parsers that read untrusted input: the SigV4 authorization
# header, timestamp and signature verification, the expression tokenizer and
# parsers, the expression evaluator, and the IAM policy document parser.
#
# Pull requests that touch crates/core, crates/auth or the fuzz crate run each
# target for a short time as a smoke test. A daily schedule runs each target
# for longer. Each run starts from the committed seeds in fuzz/seeds and writes
# the inputs it generates under $RUNNER_TEMP, outside the checkout. A crash or
# a timeout uploads the reproducing input under fuzz/artifacts.
name: Fuzz

on:
schedule:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: project-status-sweep.yml guards its schedule with if: github.repository == 'ExtendDB/extenddb' so forks don't run it. The same guard would help here, since a fork that enables Actions would queue six jobs on a runner label it doesn't have. Also the root .cargo/config.toml (the resolver setting) affects this build but isn't in the paths list, so .cargo/** might be worth adding.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 293477c, in #864. The job has if: github.repository_owner == 'ExtendDB', so a fork skips it and pull requests from forks still run here, and .cargo/** is in the pull_request paths. I check the owner because the runner group belongs to the organization.

- cron: '23 5 * * *'
workflow_dispatch:
pull_request:
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'crates/core/**'
- 'crates/auth/**'
- 'crates/cache/**'
- 'fuzz/**'
- '.github/workflows/fuzz.yml'

permissions:
contents: read

# Supersede an in-flight run of the SAME pull request when a new push arrives,
# so repeated pushes do not stack up on a capped pool. Non-PR runs get a group
# unique to the run (run_id).
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
fuzz:
# Dedicated ExtendDB runner group (Ubuntu 24.04, 4 vCPU / 16 GB, capped at
# 20 concurrent).
runs-on: extenddb_ubuntu-2404_4-core
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target:
- sigv4_authorization
- sigv4_timestamp
- sigv4_verify
- expression_parse
- expression_evaluate
- policy_document
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
# Pinned nightly: cargo-fuzz needs the sanitizer and coverage
# instrumentation that only nightly provides.
toolchain: nightly-2026-11-01

- name: Generate fuzz/Cargo.lock from Cargo.lock
# fuzz/ is its own workspace, and its lockfile is not committed. It
# starts as a copy of Cargo.lock, and cargo metadata adds the crates
# only fuzz/ uses, so the targets build each shared crate at the
# version the server builds. A version bump or dependency update in
# Cargo.lock needs no change here. For each crate that both lockfiles
# list, the awk check fails when fuzz/Cargo.lock has it at a version
# Cargo.lock does not have.
run: |
set -euo pipefail
cp Cargo.lock fuzz/Cargo.lock
cargo metadata --format-version 1 --manifest-path fuzz/Cargo.toml > /dev/null
versions() {
awk '/^\[\[package\]\]/ { p = 1; next }
p && /^name = / { n = $3 }
p && /^version = / { print n, $3; p = 0 }' "$1" | tr -d '"' | sort -u
}
versions Cargo.lock > "$RUNNER_TEMP/root-versions"
versions fuzz/Cargo.lock > "$RUNNER_TEMP/fuzz-versions"
awk 'NR == FNR { root[$0] = 1; name[$1] = 1; next }
($1 in name) && !($0 in root) {
print "fuzz/Cargo.lock has " $1 " " $2 ", which Cargo.lock does not have"
bad = 1
}
END { exit bad }' "$RUNNER_TEMP/root-versions" "$RUNNER_TEMP/fuzz-versions"

- name: Install cargo-fuzz from its .crate with a sha256 check
# Download, extract and install outside the checkout. Inside it, cargo
# install finds the root [workspace], which does not list the crate,
# and stops.
run: |
set -euo pipefail
cd "$RUNNER_TEMP"
version=0.13.2
sha256=5acfd01930e49823e58c30dd8012d3338a620377d7c7d4cc140ca4b2169400e2
curl -sSfL -o cargo-fuzz.crate \
"https://static.crates.io/crates/cargo-fuzz/cargo-fuzz-${version}.crate"
echo "${sha256} cargo-fuzz.crate" | sha256sum -c -
mkdir -p cargo-fuzz-src
tar -xzf cargo-fuzz.crate -C cargo-fuzz-src
cargo install --locked --path "cargo-fuzz-src/cargo-fuzz-${version}"

- name: Fuzz ${{ matrix.target }}
# libFuzzer writes the inputs it generates to the first corpus
# directory and only reads the second, so the committed seeds are not
# modified. The first directory has to exist before libFuzzer starts.
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
seconds=60
else
seconds=600
fi
corpus="$RUNNER_TEMP/corpus/${{ matrix.target }}"
mkdir -p "$corpus"
cargo fuzz run "${{ matrix.target }}" "$corpus" "fuzz/seeds/${{ matrix.target }}" -- \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small suggestions for the daily run. Each run starts from the seeds and throws the corpus away, and five of the six targets were still finding coverage in the last second of the 60 s PR run, so the 600 s run mostly covers the same ground each day. A corpus cache that only the scheduled job writes (and pull_request never restores), or a periodically committed cargo fuzz cmin corpus, would let it build up. Also -a would turn on overflow checks and debug_assert! in the fuzz build, which is cheap and catches arithmetic that silently wraps in release.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every run does start from fuzz/seeds and throw away what it found. The workflow avoids Actions caches on purpose, and a pull request workflow can restore a cache written on main, so #862 puts the scheduled-only cache and a committed cmin corpus side by side. On -a, cargo-fuzz 0.13.2 adds -Cdebug-assertions unless -O is passed (src/project.rs), and a local build with the workflow's flags passed it to every crate, so overflow checks and debug_assert! are on in these runs.

-max_total_time="${seconds}" \
-rss_limit_mb=4096 \
-timeout=25

- name: Upload the reproducing input on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: fuzz-artifacts-${{ matrix.target }}
path: fuzz/artifacts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the schedule runs on main of a public repo, a crash, timeout or OOM in the pre-auth SigV4 parsers would be published here as an artifact for the default retention (up to 90 days), and libFuzzer also prints small inputs in the public log. A plain panic only drops one connection, but an OOM or hang input is a DoS that SECURITY.md would want reported privately first. Might be worth setting retention-days to something short and agreeing who looks at a red scheduled run, since GitHub only emails whoever last edited the cron line.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. libFuzzer also prints a failing input of up to 256 bytes in the log as hex and base64, cargo-fuzz prints it in full, and libFuzzer writes and prints a slow input (over 10 s) even on a run that passes. In ad9ca95, in #864, the Fuzz step writes all of that to a file on the runner, prints only the final stats of a clean run, fails with a fixed message when libFuzzer fails or leaves anything under fuzz/artifacts, and uploads nothing. Getting the input to a maintainer privately, and who looks at a red scheduled run, are in #861.

if-no-files-found: ignore
9 changes: 9 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Build output and fuzzing runtime state. The committed seeds are in seeds/.
# cargo fuzz writes generated inputs to corpus/ when it is given no corpus
# directory, so corpus/ is ignored. Cargo.lock is generated from the root
# Cargo.lock before each build (see Cargo.toml).
target
artifacts
corpus
coverage
Cargo.lock
84 changes: 84 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright 2026 ExtendDB contributors
# SPDX-License-Identifier: Apache-2.0
#
# cargo-fuzz harness for the request parsers that read untrusted input. This is
# its own workspace (see the [workspace] section at the end) so the root
# workspace, its Cargo.lock and its license notices are unaffected.
#
# fuzz/Cargo.lock is not committed. Before a local build, create it the way the
# Fuzz workflow does, so every crate shared with the server is at the server's
# version: copy Cargo.lock to fuzz/Cargo.lock, then run
# `cargo metadata --format-version 1 --manifest-path fuzz/Cargo.toml`.
[package]
name = "extenddb-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[dependencies]
# Pinned for reproducibility. libfuzzer-sys brings its own libFuzzer runtime,
# built from source by its cc build script, so no system libFuzzer is needed.
libfuzzer-sys = "=0.4.13"
# Used only to turn fuzzer bytes into structured items, maps and headers.
arbitrary = "=1.5.0"
# The sigv4 verify and timestamp entry points take axum::http::HeaderMap, which
# is a re-export of this crate's HeaderMap. The same major version resolves in
# the dependency graph, so the type matches.
http = "1"

extenddb-core = { path = "../crates/core" }
extenddb-auth = { path = "../crates/auth" }

[lib]
path = "src/lib.rs"

[[bin]]
name = "sigv4_authorization"
path = "fuzz_targets/sigv4_authorization.rs"
test = false
doc = false
bench = false

[[bin]]
name = "sigv4_timestamp"
path = "fuzz_targets/sigv4_timestamp.rs"
test = false
doc = false
bench = false

[[bin]]
name = "sigv4_verify"
path = "fuzz_targets/sigv4_verify.rs"
test = false
doc = false
bench = false

[[bin]]
name = "expression_parse"
path = "fuzz_targets/expression_parse.rs"
test = false
doc = false
bench = false

[[bin]]
name = "expression_evaluate"
path = "fuzz_targets/expression_evaluate.rs"
test = false
doc = false
bench = false

[[bin]]
name = "policy_document"
path = "fuzz_targets/policy_document.rs"
test = false
doc = false
bench = false

# Independent workspace so the root workspace does not pick up the fuzz crate or
# its dependencies.
[workspace]
members = ["."]
resolver = "2"
55 changes: 55 additions & 0 deletions fuzz/fuzz_targets/expression_evaluate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

// Mirrors evaluating a parsed expression against an item, which every storage
// backend does, for example crates/storage-sqlite/src/data/query.rs and
// update_item.rs call evaluate_condition and apply_update_validated. The first
// byte selects a condition or an update; the generated maps and item stand in
// for ExpressionAttributeNames, ExpressionAttributeValues and the stored item;
// the remaining bytes are the expression string. The expression is bounded by
// max_expression_length_bytes the way the server bounds a request expression.
//
// Every generated number, in the item or in the values map, goes through
// extenddb_core::validation::number::validate_and_normalize_number. A number
// it refuses is dropped, and a number it keeps is in its normalized form.

#![no_main]

use arbitrary::{Arbitrary, Unstructured};
use libfuzzer_sys::fuzz_target;

use extenddb_core::expression::{
apply_update_validated, evaluate_condition, parse_condition_with_depth_limit,
parse_update_from, tokenize_for, tokenize_with_limit, ExpressionKind,
};
use extenddb_core::limits::LimitsConfig;

fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let is_update = bool::arbitrary(&mut u).unwrap_or(false);
let maps = extenddb_fuzz::arb_expression_maps(&mut u);
let mut item = extenddb_fuzz::arb_item(&mut u);
let rest = u.take_rest();
let Ok(expr) = std::str::from_utf8(rest) else {
return;
};
let limits = LimitsConfig::default();
if expr.len() > limits.max_expression_length_bytes {
return;
}
let max_tokens = limits.max_expression_tokens;
if is_update {
if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Update) {
if let Ok(actions) = parse_update_from(&tokens, expr) {
// Empty vector-index and attribute-definition slices: a table
// without vector indexes, which is the common case and a valid
// call (see the Cassandra update path).
let _ = apply_update_validated(&actions, &mut item, &maps, &[], &[]);
}
}
} else if let Ok(tokens) = tokenize_with_limit(expr, max_tokens) {
if let Ok(ast) = parse_condition_with_depth_limit(&tokens, limits.max_expression_depth) {
let _ = evaluate_condition(&ast, &item, &maps);
}
}
});
64 changes: 64 additions & 0 deletions fuzz/fuzz_targets/expression_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

// Mirrors the engine expression pipeline in
// crates/engine/src/expression_helpers.rs, which the server runs on
// ConditionExpression, KeyConditionExpression, ProjectionExpression and
// UpdateExpression: a byte-size check against max_expression_length_bytes, then
// tokenize with max_expression_tokens, then parse with max_expression_depth.
// The first input byte selects the parser; the rest is the expression string.
// The limits are LimitsConfig::default(), the server's defaults. The reserved
// word check is left out so the parsers are reached on every token stream the
// tokenizer can emit.
//
// The authorization leading-keys check in crates/server/src/leading_keys.rs
// parses a KeyConditionExpression with its own sequence of calls and no
// byte-size check. This target does not run that sequence.

#![no_main]

use libfuzzer_sys::fuzz_target;

use extenddb_core::expression::{
parse_condition_with_depth_limit, parse_key_condition, parse_projection, parse_update_from,
tokenize_for, tokenize_with_limit, ExpressionKind,
};
use extenddb_core::limits::LimitsConfig;

fuzz_target!(|data: &[u8]| {
if data.is_empty() {
return;
}
let which = data[0] % 4;
let Ok(expr) = std::str::from_utf8(&data[1..]) else {
return;
};
let limits = LimitsConfig::default();
// The engine rejects an over-size expression before tokenizing.
if expr.len() > limits.max_expression_length_bytes {
return;
}
let max_tokens = limits.max_expression_tokens;
match which {
0 => {
if let Ok(tokens) = tokenize_with_limit(expr, max_tokens) {
let _ = parse_condition_with_depth_limit(&tokens, limits.max_expression_depth);
}
}
1 => {
if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::KeyCondition) {
let _ = parse_key_condition(&tokens);
}
}
2 => {
if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Projection) {
let _ = parse_projection(&tokens);
}
}
_ => {
if let Ok(tokens) = tokenize_for(expr, max_tokens, ExpressionKind::Update) {
let _ = parse_update_from(&tokens, expr);
}
}
}
});
26 changes: 26 additions & 0 deletions fuzz/fuzz_targets/policy_document.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

// Mirrors the parse paths for identity policies and permissions boundaries. On
// a write, crates/server/src/management/iam_policy.rs and
// permissions_boundary.rs call PolicyDocument::from_json_for_write; on the
// request path, crates/server/src/authz_cache.rs calls PolicyDocument::from_json
// on a stored identity policy, permissions boundary or session policy.
// from_json caps the document at 6,144 bytes itself. Both are driven here with
// the same arbitrary JSON text. A trust policy is checked on write by
// validate_trust_policy in crates/server/src/management/assume_role.rs, which
// this target does not call. AssumeRole evaluates its conditions with
// parse_conditions, which from_json also calls.

#![no_main]

use libfuzzer_sys::fuzz_target;

use extenddb_auth::policy::document::PolicyDocument;

fuzz_target!(|data: &[u8]| {
if let Ok(json) = std::str::from_utf8(data) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: about half of this target's coverage is serde_json (669 edges, against 152 in policy/document.rs), since most mutations break the JSON. parse_principal_value wasn't reached and the Sid check in from_json_for_write only barely. A -dict file with the policy keywords (Sid, NotResource, Principal, Condition, StringLike, ForAllValues:, IfExists, aws:SourceIp, ...) and a seed or two with those shapes would probably help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7693150 in #864 adds fuzz/dicts/policy_document.dict with the policy keys, the Effect and Version values, every condition operator with its ForAllValues:, ForAnyValue: and IfExists forms, and a few CIDRs, and the workflow passes it with -dict. Two new seeds cover Principal, NotPrincipal, NotResource, a valid and a refused Sid, and the other operator families. In four local 60 s runs from the old seeds, no corpus input had a Principal, NotPrincipal or Sid key. With the new seeds and the dictionary, each corpus had 63 to 117 statements with a Principal and 92 to 122 with a Sid.

let _ = PolicyDocument::from_json(json);
let _ = PolicyDocument::from_json_for_write(json);
}
});
19 changes: 19 additions & 0 deletions fuzz/fuzz_targets/sigv4_authorization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

// Mirrors the server authentication entry point. In crates/server/src/handler.rs
// the handler calls state.auth.authenticate, which in crates/auth/src/lib.rs
// calls sigv4::parse::parse_authorization on the Authorization header string.
// parse_authorization caps the header at 8 KB itself, so no extra bound is
// applied here.

#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
// The header arrives as an HTTP header value, which is read as a string.
if let Ok(header) = std::str::from_utf8(data) {
let _ = extenddb_auth::sigv4::parse::parse_authorization(header);
}
});
Loading
Loading