0027 — runtime security hardening¶
Status: IMPLEMENTED (promoted from the external review to Phase 0 of the
implementation roadmap and built. Host fixes 4A + 4B shipped in
afmpeg with -race acceptance tests; engine fix 4C is committed in ffmpeg-wasi/src/process.c
and ships in the next routine engine rebuild. D-0027-B resolved to 512 MB; D-0027-C resolved
to impose-a-default-deadline (1 h) with override.)
Date: 2026-07-02
Parent: 0007 (the engine this hosts); 0004 (the Runtime/New surface these options extend)
Source: external commissioned review — ffmpeg-wasi/security_review.md + security_review_supplementary.md
Owns: R-SEC-HARDENING
1. Why — the sandbox is the product, and these gaps undercut it¶
afmpeg's entire thesis (0012 §2; 0001) is "safely process untrusted media": FFmpeg is a huge C codebase with a long history of demuxer/decoder memory bugs, and the wazero WASM sandbox contains any RCE a crafted file triggers to the guest's linear memory — the guest can't touch the host disk, env, or network. That containment is real and is the reason to exist.
But containment of code execution is not containment of resource abuse. The review confirms two host-side denial-of-service holes and one guest-side robustness nit that let a crafted (or merely pathological) input harm the host without ever escaping the sandbox:
- a decoder that allocates multi-GB can OOM-kill the host Go process (no memory ceiling), and
- a decode loop that never terminates can hang an invocation and wedge the
Runtime(no enforced deadline).
Because the sandbox is the value proposition, a gap that lets untrusted media take down the host is higher-priority than a generic robustness fix would be — it directly falsifies the headline promise. This spec closes the two DoS holes (finding 1 is the headline) and tidies the JSON nit, all as additive, back-compatible options with safe defaults.
2. Validation — the three findings (all code-confirmed)¶
- No wazero memory limit (OOM risk) — the important one.
runtime.goNew()builds the runtime aswazero.NewRuntimeConfig().WithCoreFeatures(runtimeCoreFeatures).WithCloseOnContextDone(true)— there is noWithMemoryLimitPages(...). So the guest can grow to the wasm32 maximum (~4 GB). A crafted file declaring huge dimensions (e.g. 65535×65535) makes libavcodec/ libavfilter allocate gigabytes for uncompressed frames, consuming host RAM until the OS OOM-kills the process. This directly defeats "safely process untrusted media." - No hard timeout / mutex-hold risk.
Run()takesr.mu.Lock()for the whole invocation (serialise-one-at-a-time, 0004 D-0004-B).WithCloseOnContextDone(true)is set (good — a cancelled ctx aborts the guest), but nothing enforces a deadline. A consumer passingcontext.Background()into a pathological decode loop hangs the invocation and holdsr.muforever, permanently wedging theRuntime(and anyRunJob/Probebehind the same mutex). - cJSON type guards (minor robustness).
process.cparse_outputiterates the options withcJSON_ArrayForEach(kv, opts) { if (cJSON_IsString(kv)) … }without first checkingcJSON_IsObject(opts). A malformedoptions(e.g. a JSON string instead of an object) iterates unpredictably. Low severity —optionscomes from the trusted caller's job spec, not the untrusted media — but the guard is cheap and belongs with the other two.
3. Scope¶
- In: a configurable guest memory ceiling with a safe default (finding 1); a deadline
policy for
Run/RunJob/Probe(finding 2); nested-type guards inprocess.cparse_output(finding 3). New host options, additive; one small engine-side C change. - Out: CPU-time metering beyond the deadline; per-op resource budgets; changing the one-at-a-time serialisation model (0004 D-0004-B) or the vfs; anything that touches the trust/signing model (0010/0011). No new dependency, no licence impact (native/Go, nil).
4. Approach — per fix¶
4A. The memory ceiling (finding 1 — the headline)¶
wazero's RuntimeConfig.WithMemoryLimitPages(pages) caps guest linear memory (one page = 64 KiB);
past the cap a guest memory.grow fails, so libav's allocation returns an error the guest handles
(clean AVERROR(ENOMEM) → non-zero exit) instead of the host being OOM-killed. The fix:
- A new host option
WithMemoryLimit(bytes int)(bytes for ergonomics; internally converted to pages, rounded up:pages = ceil(bytes / 65536), clamped to the wasm32 max of 65536 pages / 4 GB). - Applied in
New()on theRuntimeConfigbefore compile:…WithMemoryLimitPages(pages). - A default is set even when the option is absent — the whole point is that the out-of-the-box
runtime is safe, not only a runtime a careful consumer opts into. Default proposed 512 MB
(D-0027-B, open).
WithMemoryLimit(0)(or a sentinel) means "no cap" for the rare consumer who knowingly wants the old behaviour.
4B. The deadline policy (finding 2)¶
WithCloseOnContextDone(true) already makes a cancelled context abort the guest; the gap is that
a context might never be cancelled. Fix — enforce a deadline at the Run/RunJob/Probe boundary:
- A new host option
WithTimeout(d time.Duration)sets a default maximum invocation duration. - Before locking
r.muand invoking, if the caller's ctx has no deadline, derive one:ctx, cancel = context.WithTimeout(ctx, effectiveTimeout); defer cancel(). If the caller's ctx already has an earlier deadline, honour it (take the min — never extend the caller's bound). - On expiry the existing
exitCodeFrompath already mapsctx.Err()to a wrapped"invocation aborted"error, andWithCloseOnContextDonetears down the guest — so the mutex is released and theRuntimestays usable. D-0027-C (open): impose-a-default-deadline (proposed) vs refuse-a-context-without-a-deadline (stricter, but a harsher API contract).
4C. The JSON guards (finding 3)¶
In process.c parse_output, guard the container type before iterating: only walk options when
cJSON_IsObject(opts) (and, defensively, apply the same shape to any other nested-container walk
that assumes an object/array). Cheap, keeps the driver predictable on a malformed-but-trusted spec.
Low severity, bundled here so the review's three items land together.
5. API impact — additive, back-compatible¶
Two new Options on New (0004 surface), no signature changes, no behaviour change for existing
callers except the safer defaults:
rt, err := afmpeg.New(ctx,
afmpeg.WithModuleRelease("n8.1.2-5", afmpeg.VariantLGPL),
afmpeg.WithMemoryLimit(512<<20), // 512 MB guest ceiling (default if omitted)
afmpeg.WithTimeout(1*time.Hour), // per-invocation deadline (default if omitted)
)
- Defaults do the protecting. Omitting both yields a hardened runtime (512 MB cap + a default deadline), not today's unbounded one — this is the deliberate, and only, behavioural change. It is a safety tightening, not a break: existing well-behaved workloads fit comfortably under a 512 MB / 1-hour envelope.
WithTimeoutinteracts withWithCloseOnContextDone(already on) — the option supplies the deadline that teardown then acts on; the two compose.- No change to
WithModuleURL/WithModuleRelease, the vfs, or theCommand/job-spec vocabulary. Engine change (4C) is internal to the driver; no vocabulary bump.
6. Decisions + open questions¶
- D-0027-A — harden by default, opt out explicitly. ADOPTED. The default
Runtimeis safe (a memory cap and a deadline apply with no option set); a consumer must explicitly pass a sentinel (WithMemoryLimit(0)/WithTimeout(0)) to remove a bound. A sandbox whose protections are off-by-default is not a sandbox. - D-0027-B — memory ceiling as bytes, default value. RESOLVED → 512 MB. Option takes bytes
(converted to pages internally). Chose the review's lower suggestion over 1 GB: safer for the
multi-tenant/
RuntimePool(0006 §2E) case, andWithMemoryLimitraises it for a legitimate 4K job. Implemented asdefaultMemoryLimitBytes = 512 << 20inruntime.go. - D-0027-C — deadline policy: impose vs refuse. RESOLVED → impose-with-override.
Impose-a-default-deadline (friendlier —
context.Background()still works, just bounded) over refuse-a-context-without-a-deadline (harsher API contract). DefaultdefaultTimeout = 1 * time.Hour;Runimposes it only when the caller's context carries no deadline, and honours an earlier caller deadline as-is (never extends it). - D-0027-D — JSON guard scope. ADOPTED. Guarded the
optionswalk withcJSON_IsObjectinparse_output; did not attempt a full schema validator — the job spec is trusted input, so this is defence-in-depth, not a security boundary.
7. Requirements¶
- R-SEC-HARDENING — Untrusted media processed through afmpeg cannot, by resource abuse alone,
take down or wedge the host: (a) guest linear memory is capped at a configurable ceiling
(default-on) so an over-allocating decode fails cleanly instead of OOM-killing the host;
(b) every invocation runs under an enforced deadline (default-on) so a non-terminating decode
cannot hold
r.muindefinitely; © the engine's job-spec parsing guards nested-container types before iterating. All additive and back-compatible bar the safer defaults.
8. Test surface¶
- Memory ceiling (finding 1). A fixture/job that provokes a large libav allocation (a spec or
crafted input declaring outsized dimensions) run under a low
WithMemoryLimit→ assert a clean non-zero exit / bounded error, and that the host process survives (RSS stays bounded), i.e. anENOMEM-style guest failure, not a host OOM. The negative control: the same job under a generous cap succeeds. - Deadline (finding 2). An invocation given a context with no deadline against a
never-terminating (or artificially long) job → assert it returns a bounded "aborted" error
within ~the configured timeout,
r.muis released, and a subsequentRunon the sameRuntimesucceeds (proving no permanent wedge). Also: a caller deadline earlier than the default is honoured (min, not extended). - JSON guards (finding 3).
process.cunit/integration: a job spec whoseoptionsis a string (not an object) → parsed without misbehaviour, predictable result (skip/ignore), no crash. - Standard bars per the dev method: table-driven,
t.Parallel(),go test -raceclean; the two DoS tests are the load-bearing acceptance criteria.
9. Dependencies¶
- wazero — already a dependency;
WithMemoryLimitPagesis in the pinned version (no new dep). - ffmpeg-wasi engine (0007) for 4C — a one-line-scale guard in
src/process.c; ships in a routine engine rebuild (nextn8.1.2-N), no vocabulary bump, no afmpeg re-pin required. - Independent of the trust/signing specs (0010/0011) and the parity roadmap (0012). Composes with
RuntimePool(0006 §2E) — a per-instance memory cap is exactly what a multi-tenant pool wants.
10. Definition of done¶
The decision on this PROPOSED spec is recorded. If accepted: a default afmpeg.New runtime caps
guest memory and enforces an invocation deadline with no option required; WithMemoryLimit and
WithTimeout let a consumer tune or (explicitly) remove each bound; parse_output guards its
nested-container walks. The two host-side DoS tests (over-allocation → clean failure not host OOM;
no-deadline hang → bounded, Runtime still usable) pass under -race, D-0027-B/C are resolved, and
the how-to/reference docs note the new defaults and options. If declined: the findings stand
recorded here as known, accepted residual risk.