Skip to content

fix: cap RIAPI list parsing and gate Filename IO behind trust flag - #723

Open
lilith wants to merge 4 commits into
mainfrom
fix/security-h1-h3-2026-05-06
Open

fix: cap RIAPI list parsing and gate Filename IO behind trust flag#723
lilith wants to merge 4 commits into
mainfrom
fix/security-h1-h3-2026-05-06

Conversation

@lilith

@lilith lilith commented May 6, 2026

Copy link
Copy Markdown
Member

Addresses two HIGH-severity items from the 2026-05-06 security audit
(feedback/security-audit-2026-05-06/imageflow.md). No memory-safety
bugs 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), and
parse_f64_list::<N> (927–951) 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,... payload of N entries produced
an O(N) Vec allocation (~24 bytes per Result<f64, ParseFloatError>).

At 64 MB the worst-case allocation was ~384 MB per parse, repeatable
per CommandString node in the same graph.

Fix: introduce MAX_RIAPI_LIST_VALUES = 16 and apply
.take(MAX_RIAPI_LIST_VALUES + 1) to every split(',') site in those
four 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 was
ignored). 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_capped
constructs ?crop=1,1,... with 1 million entries and asserts the
parse completes in bounded memory and emits a ValueInvalid warning.

H-1: gate IoEnum::Filename behind ExecutionSecurity::allow_io_filename (2fb0d70f)

imageflow_core/src/parsing.rs:122 opens any path the imageflow
process can read or write when a JSON job specifies
IoEnum::Filename. The existing comment 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:

  • arbitrary file read (12-byte magic-byte sniff leak via error
    messages, full read for files matching a registered codec),
  • arbitrary file write at process privilege (output paths are opened
    with OpenOptions::write().create().truncate()).

Fix: new field ExecutionSecurity::allow_io_filename: Option<bool>.
The IoTranslator::add match arm for IoEnum::Filename now checks
c.security.allow_io_filename; Some(false) returns an
InvalidArgument error 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() returns
Some(true), so the CLI tool, embedding bindings, and any current
JSON caller continue to work unchanged. HTTP frontends accepting
untrusted JSON should call Context::configure_security once at
Context creation with allow_io_filename = Some(false).

unspecified() returns None, which is treated as Some(true) for
compatibility — server frontends should set this explicitly to
Some(false). The field-level rustdoc on ExecutionSecurity makes
this recommendation explicit.

Tests in 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 the path is not created on disk.
  • test_io_filename_allowed_by_defaultsane_defaults still
    permits; 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 | null field appears on the
ExecutionSecurity object in openapi_schema_v1.json. Existing
clients 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_filename to ExecutionSecurity,
which Context holds inline. Option<bool> rounds up to the next
8-byte alignment due to surrounding usize/u64 fields, so
ThreadSafeContext grew from 560 to 568 bytes. The
test_thread_safe_context_size resource-budget assertion needed an
8-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 Context is preferred (e.g., bool with a default of true
instead of Option<bool>), I'll happily rework — but Option<bool>
matches the merge-on-Some semantics of every other field on
ExecutionSecurity, so it's the consistent shape.

Items deferred (not in this PR)

  • L-8 (decoders other than WebP don't enforce max_input_file_bytes):
    recommended fix wraps IoProxy in Read::take(max_bytes) for all
    decoders; not a one-file change. Tracked for follow-up.
  • L-1 (subprocess dot for graph recording leaks files into cwd):
    recommended trusted-policy axis allow_graph_recording. Defer to
    follow-up — the audit notes this is auto-disabled when CI=TRUE.

Validation

  • cargo fmt (touched files only)
  • cargo test -p imageflow_riapi — 14 + 7 passed
  • cargo test -p imageflow_core --lib — 61 passed (incl. the size
    budget 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 passed
  • cargo test -p imageflow_abi — 9 passed
  • cargo test -p imageflow_tool_lib -p imageflow — passed
  • cargo test --workspace --doc — passed
  • 18 visuals::* tests fail on this host both on main and on this
    branch — pre-existing baseline mismatches against the local AVX-512
    machine (solar-lotus-8e1f48c58b:sea vs novel-pulse-c8d51eb293:sea),
    unrelated to this change. Verified by running the same single test
    on main with my changes stashed.

Test plan

  • CI passes on all platforms (linux x86_64, linux i686, windows-11-arm,
    macos-15-intel, macos-latest)
  • No new clippy warnings on touched files (workspace clippy is
    currently red on imageflow_types/build.rs lines 99–105 — pre-existing)
  • Reviewer confirms the default-true posture for allow_io_filename
    vs deny-by-default tradeoff (PR body explains why we picked default-true)
  • Reviewer confirms the 8-byte ThreadSafeContext size bump is
    acceptable for the security gain

lilith added 3 commits May 6, 2026 04:25
`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.
@lilith lilith self-assigned this May 6, 2026
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.
@lilith

lilith commented May 6, 2026

Copy link
Copy Markdown
Member Author

Revision pushed to close the JSON-job bypass identified in review (commit 40e26e8).

Fix: configure_security is now monotonic-deny on allow_io_filename — once set to Some(false), subsequent calls passing Some(true) are silently ignored. Job-supplied security (from Build001Config.security / Execute001.security) now routes through a new apply_job_security that returns Result<()> and rejects widening attempts with ErrorKind::InvalidArgument before any IO opens. So the attack {"builder_config":{"security":{"allow_io_filename":true}}} against a Context that already set Some(false) now errors out instead of re-enabling Filename IO.

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 configure_security API stays infallible (no breaking signature change) but is monotonic-deny, while the untrusted JSON entry point is fallible and explicitly rejects widening. Tightening Some(true) -> Some(false) remains allowed in both paths.

Default unchanged. sane_defaults() still returns Some(true) for allow_io_filename, and unspecified() still returns None. Switching the default to Some(false) is a behavior change for CLI/embedded callers and should be a separate decision — flagging as follow-up.

Tests added:

  • test_job_security_cannot_re_enable_filename_io — Context with Some(false) rejects a JSON job carrying allow_io_filename: true with InvalidArgument, and the deny is still in place after.
  • test_job_security_can_narrow_default_context — default Context accepts a JSON job that narrows to Some(false), and a subsequent widening attempt is rejected.

Both pass; full cargo test -p imageflow_core --lib is green (63/63).

@lilith

lilith commented May 7, 2026

Copy link
Copy Markdown
Member Author

@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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant