fix: cap RIAPI list parsing and gate Filename IO behind trust flag - #723
fix: cap RIAPI list parsing and gate Filename IO behind trust flag#723lilith wants to merge 4 commits into
Conversation
`parse_crop`, `parse_crop_strict`, `parse_round_corners`, and
`parse_f64_list::<N>` previously called `.collect::<Vec<_>>()` over a
`split(',')` iterator with no upper bound on entry count. RIAPI values
can originate from `Node::CommandString` JSON entries whose length is
bounded only by `ExecutionSecurity::max_json_bytes` (default 64 MB), so
a malicious `?crop=1,1,1,...` payload of N entries produced an O(N) Vec
allocation (~24 bytes per `Result<f64, ParseFloatError>`).
At max_json_bytes=64 MB, the worst case was ~384 MB of attacker-controlled
allocation per parse, repeatable per CommandString node in the same graph.
Cap iterator consumption with `.take(MAX_RIAPI_LIST_VALUES + 1)` (=17).
Excess values surface as the standard wrong-count warning instead of
allocating. Every RIAPI list field defined today expects at most 4
values, so 16 is generous headroom.
Audit reference: H-3 (HIGH) in security-audit-2026-05-06/imageflow.md.
…io_filename JSON jobs can specify `IoEnum::Filename(path)` to read or write any filesystem path the imageflow process has access to. The existing comment at parsing.rs notes "no path traversal protection is applied here — this is by design" and relies on the caller to validate paths at the HTTP/API layer. For HTTP frontends accepting untrusted JSON, this amounts to an arbitrary file read (12-byte magic-byte sniff leak via error messages, full read for files matching a registered codec) and arbitrary file write at process privilege (output paths use `OpenOptions::write().create().truncate()`). This commit adds a new field `ExecutionSecurity::allow_io_filename: Option<bool>` and gates `IoEnum::Filename` matching in `IoTranslator::add` accordingly: - `Some(false)`: requests with `IoEnum::Filename` are rejected with an `InvalidArgument` error before any file is opened or created. - `Some(true)` / `None`: existing behavior preserved (the file is opened by `Context::add_file` as before). `ExecutionSecurity::sane_defaults()` returns `Some(true)` so existing callers (CLI, embedding bindings) remain unaffected. HTTP frontends should call `Context::configure_security` once at Context creation with `allow_io_filename = Some(false)` to disable filesystem IO for all incoming JSON jobs. Tests added to `imageflow_core/tests/integration/robustness.rs`: - `test_io_filename_blocked_when_disabled` (input filename denied) - `test_io_filename_blocked_for_output_when_disabled` (output filename denied; verified no file is created on disk) - `test_io_filename_allowed_by_default` (sane_defaults still permits; failure for nonexistent path comes from IO open, not the gate) OpenAPI schema regenerated (the new field appears as `allow_io_filename: boolean | null` on the ExecutionSecurity object). Audit reference: H-1 (HIGH) in security-audit-2026-05-06/imageflow.md.
… 560) H-1 added `Option<bool> allow_io_filename` to `ExecutionSecurity`, which the `Context` struct holds inline. The Option<bool> rounds up to the next 8-byte alignment due to surrounding `usize`/`u64` fields, producing a deterministic +8-byte growth in `ThreadSafeContext`. The previous threshold (`<= 560`) caught the size change in CI; this commit bumps it to `<= 568` to reflect the intentional growth and adds an explanatory comment so a future reviewer can trace why. Also regenerates the OpenAPI schema hash (the schema text was already updated in the prior H-1 commit; only the hash digest of the relevant sources needed refreshing — `imageflow_types/src/lib.rs` is part of the hash input set). NOTE TO REVIEWERS: this is a *resource budget threshold* assertion, not a correctness assertion. Per CLAUDE.md, threshold relaxations require explicit approval. The size change is a direct, audited consequence of H-1's new field; if you would prefer a representation that does not grow `Context` (e.g., dropping `Option` and using `bool` defaulted to `true`), say the word and I will rework the API.
Closes the bypass identified in PR #723 review: a JSON request body with `{"builder_config":{"security":{"allow_io_filename":true}}}` could re-enable Filename IO that the server disabled at Context creation, because `build_inner`/`execute_inner` routed the job-supplied policy through `configure_security` which followed an "if specified, override" pattern. Two changes: 1. `configure_security` (trusted Context-level path) is now monotonic-deny on `allow_io_filename`: once set to `Some(false)`, subsequent calls passing `Some(true)` are silently ignored. Tightening Some(true) -> Some(false) is always allowed. 2. New `apply_job_security` (untrusted job path) returns `Result<()>` and explicitly rejects job-supplied policies that try to widen allow_io_filename from `Some(false)` to `Some(true)` with `ErrorKind::InvalidArgument`. `build_inner` and `execute_inner` now route through this method so a malicious JSON job is rejected before any IO is opened. Other fields delegate to `configure_security`. Default of `allow_io_filename` is unchanged (still `Some(true)` in sane_defaults, `None` in unspecified) — that's a separate behavior decision for a follow-up. Two regression tests: - `test_job_security_cannot_re_enable_filename_io`: Context with Some(false) rejects job with allow_io_filename=true. - `test_job_security_can_narrow_default_context`: default Context accepts job narrowing to Some(false), and a subsequent widen attempt is rejected.
|
Revision pushed to close the JSON-job bypass identified in review (commit 40e26e8). Fix: Per the audit summary's recommendation, this is closer to "intersect-only" semantics for the JSON-job path (Option B in the review note) — the public Default unchanged. Tests added:
Both pass; full |
|
@lilith — ready for review. Part of the 2026-05-06 security audit campaign (see /home/lilith/work/feedback/security-audit-2026-05-06/FIX-RESULTS.md and REVIEW-RESULTS.md). |
Addresses two HIGH-severity items from the 2026-05-06 security audit
(
feedback/security-audit-2026-05-06/imageflow.md). No memory-safetybugs were touched — both items are DoS / operational-risk fixes for
production HTTP-endpoint deployments. This is the imageflow
production endpoint, so behavior changes are flagged below.
H-3: cap RIAPI list parsing at 16 entries (
3d97a975)imageflow_riapi/src/ir4/parsing.rs::parse_crop_strict(line 766–792),parse_crop(793–809),parse_round_corners(811–839), andparse_f64_list::<N>(927–951) previously called.collect::<Vec<_>>()over a
split(',')iterator with no upper bound on entry count. RIAPIvalues can originate from
Node::CommandStringJSON entries whoselength is bounded only by
ExecutionSecurity::max_json_bytes(default64 MB), so a malicious
?crop=1,1,...payload of N entries producedan O(N) Vec allocation (~24 bytes per
Result<f64, ParseFloatError>).At 64 MB the worst-case allocation was ~384 MB per parse, repeatable
per
CommandStringnode in the same graph.Fix: introduce
MAX_RIAPI_LIST_VALUES = 16and apply.take(MAX_RIAPI_LIST_VALUES + 1)to everysplit(',')site in thosefour parsers. Excess values surface as the standard wrong-count warning
instead of allocating. Every RIAPI list field defined today expects at
most 4 values, so 16 is generous headroom.
Behavior change: previously, a request with > 16 comma-separated
values in a list field eventually completed parsing and silently
produced
Err(())from the wrong-count branch (and the field wasignored). It still does the same — just much earlier and without the
allocation. No legitimate caller produces > 16 entries today.
Test:
imageflow_riapi/src/ir4/parsing.rs::test_riapi_list_oom_dos_cappedconstructs
?crop=1,1,...with 1 million entries and asserts theparse completes in bounded memory and emits a
ValueInvalidwarning.H-1: gate
IoEnum::FilenamebehindExecutionSecurity::allow_io_filename(2fb0d70f)imageflow_core/src/parsing.rs:122opens any path the imageflowprocess can read or write when a JSON job specifies
IoEnum::Filename. The existing comment notes "no path traversalprotection is applied here — this is by design" and relies on the
caller to validate paths at the HTTP/API layer. For HTTP frontends
accepting untrusted JSON, this amounts to:
messages, full read for files matching a registered codec),
with
OpenOptions::write().create().truncate()).Fix: new field
ExecutionSecurity::allow_io_filename: Option<bool>.The
IoTranslator::addmatch arm forIoEnum::Filenamenow checksc.security.allow_io_filename;Some(false)returns anInvalidArgumenterror before any file is opened or created.Default behavior preserved. Per the task brief ("If H-1 changes
default behavior, propose adding a config flag with a sensible default
rather than breaking existing servers"),
sane_defaults()returnsSome(true), so the CLI tool, embedding bindings, and any currentJSON caller continue to work unchanged. HTTP frontends accepting
untrusted JSON should call
Context::configure_securityonce atContext creation with
allow_io_filename = Some(false).unspecified()returnsNone, which is treated asSome(true)forcompatibility — server frontends should set this explicitly to
Some(false). The field-level rustdoc onExecutionSecuritymakesthis recommendation explicit.
Tests in
imageflow_core/tests/integration/robustness.rs:test_io_filename_blocked_when_disabled— inputFilenamedenied.test_io_filename_blocked_for_output_when_disabled— outputFilenamedenied; verified the path is not created on disk.test_io_filename_allowed_by_default—sane_defaultsstillpermits; failure for nonexistent path comes from IO open, not the
gate (verified via error message inspection).
Schema impact: OpenAPI schema regenerated; the new
allow_io_filename: boolean | nullfield appears on theExecutionSecurityobject inopenapi_schema_v1.json. Existingclients that ignore unknown fields (the JSON spec's default) are
unaffected; clients that round-trip the schema will see one new field.
ThreadSafeContext size budget bumped from 560 to 568 (
29be5744)H-1 added
Option<bool> allow_io_filenametoExecutionSecurity,which
Contextholds inline.Option<bool>rounds up to the next8-byte alignment due to surrounding
usize/u64fields, soThreadSafeContextgrew from 560 to 568 bytes. Thetest_thread_safe_context_sizeresource-budget assertion needed an8-byte bump to match.
This is a threshold relaxation and per CLAUDE.md normally requires
explicit sign-off. The relaxation is the minimum needed to absorb
H-1's deliberate field addition; if a representation that doesn't
grow
Contextis preferred (e.g.,boolwith a default oftrueinstead of
Option<bool>), I'll happily rework — butOption<bool>matches the merge-on-
Somesemantics of every other field onExecutionSecurity, so it's the consistent shape.Items deferred (not in this PR)
max_input_file_bytes):recommended fix wraps
IoProxyinRead::take(max_bytes)for alldecoders; not a one-file change. Tracked for follow-up.
dotfor graph recording leaks files into cwd):recommended trusted-policy axis
allow_graph_recording. Defer tofollow-up — the audit notes this is auto-disabled when
CI=TRUE.Validation
cargo fmt(touched files only)cargo test -p imageflow_riapi— 14 + 7 passedcargo test -p imageflow_core --lib— 61 passed (incl. the sizebudget test)
cargo test -p imageflow_core --test integration robustness—22 passed (incl. all 3 new H-1 tests)
cargo test -p imageflow_core --test schema— 1 passedcargo test -p imageflow_abi— 9 passedcargo test -p imageflow_tool_lib -p imageflow— passedcargo test --workspace --doc— passedvisuals::*tests fail on this host both on main and on thisbranch — pre-existing baseline mismatches against the local AVX-512
machine (
solar-lotus-8e1f48c58b:seavsnovel-pulse-c8d51eb293:sea),unrelated to this change. Verified by running the same single test
on
mainwith my changes stashed.Test plan
macos-15-intel, macos-latest)
currently red on
imageflow_types/build.rslines 99–105 — pre-existing)allow_io_filenamevs deny-by-default tradeoff (PR body explains why we picked default-true)
ThreadSafeContextsize bump isacceptable for the security gain