feat: implement the code mode runtime Actor - #1
Conversation
aed71df to
3286abf
Compare
3286abf to
096ea5b
Compare
Write { stdout, stderr, exitCode } instead of { stdout, stderr }. exitCode is
the user script's effective status: 0 when it returns normally, 1 when it
throws. The Actor run itself still SUCCEEDS on a throw, so callers detect a
failed script via exitCode rather than heuristics on stderr (console.error /
console.warn are legitimate log channels). Update README and API docs.
The workerd nodejs_compat flag exposed Node built-ins to user code, which broke the sandbox's egress boundary two ways (ai-team#216): - node:net gave a raw-socket egress path that bypassed guard.js's *.apify.com fetch allowlist — arbitrary public hosts were reachable (finding A). - process.env exposed the run's APIFY_TOKEN to user code (finding B). It also made the docs' 'no imports' claim false (finding C). runner.js and guard.js use only web-standard APIs (fetch/URL/Response/ Uint8Array), so dropping the flag needs no code changes. Without it, node:* imports fail and process/require are undefined, while fetch and the apify binding keep working. Add tests/sandbox-isolation.js (run via test.sh, same deploy+call pattern as binding-smoke.js) asserting node builtins are blocked, process/require are undefined, fetch works, guard.js still blocks non-apify hosts, and the apify binding still works. Verified on the deployed Actor: isolation 9/9, binding smoke 18/18. Update docs to state no imports are available.
Expand the sandbox isolation probe: positively verify apify.com and an *.apify.com subdomain are allowed, and that guard.js blocks unrelated hosts, subdomain/userinfo look-alikes (evilapify.com, apify.com.evil.com, apify.com@evil.com), and the cloud metadata IP. Classify by the guard's 'Blocked fetch' rejection, not by request success. Verified on the deployed Actor: 15/15.
Removing nodejs_compat closed the node:net egress path, but WebSocket and EventSource are web-standard globals present without that flag and connect directly, not through the fetch guard. A script could open a wss:// or SSE channel to any public host and exfiltrate data around the *.apify.com allowlist (apify/ai-team#216 finding A, second primitive). runner.js and the apify binding use only fetch, so neutralize both globals (non-configurable throwing stubs). This closes the JS egress surface: fetch is allowlisted, WebSocket/EventSource are removed, and raw sockets need module imports that are already blocked. Extend the isolation probe with wss:// and SSE egress cases. Verified on the deployed Actor: isolation 17/17, binding 18/18.
|
I wanted to add myself as an a reviewer, but I can't, so here we go. First: nice work. The big calls are right, and the ones that are easy to get wrong you got right. The rest is written mostly by Claude with my edits. Things I want to agree on1. Write the Actor's own code in TypeScript. 2. 3. Give the runner a real status message.
4. The binding docs have to live in the Actor. Stop renaming the Apify vocabulary and this is not bike-shedding, it is important for LLMsThe binding renames a lot of Rule I'd propose: match the API / Note: I like
The run-option paramsHere's the full picture across every layer, because it's the confusing bit:
FindingsSecurity — I'd fix these before it leaves experimental
Robustness
Contract / maintainability
Minor
Confirmed good — leave these alone |
guard.js exported realFetch as a standing module binding. Since ES modules
are singleton-cached, the sandboxed user script could recover it at runtime
via `(await import('./guard.js')).realFetch(...)`, fully bypassing the
*.apify.com fetch allowlist (and reaching Apify's private network, since the
outbound service allows public/private/local). Reopened exactly the egress
class apify/ai-team#216 was meant to close.
guard.js now exposes claimRealFetch(), a one-shot accessor that hands out the
real fetch once and nulls its internal reference. runner.js claims it during
its own module load, strictly before usercode.js can run. A later
import('./guard.js') from inside the script reaches the same cached module
instance, but the value is already gone.
Verified against real workerd, before/after, same PoC:
- before: guard.js exports ["realFetch"] -> BYPASS SUCCEEDED, exfil request
logged on a disallowed listener.
- after: guard.js exports ["claimRealFetch"] -> BYPASS FAILED (null), zero
hits on the disallowed listener.
runner.js: r -> response, d -> page, [k, v] -> [key, value], ct -> contentType / resolvedContentType. Matches code-quality and apify-coding-standards naming rules (no single-letter/abbreviated locals). config.capnp + entrypoint.sh both hardcoded the loopback port 8787 independently. config.capnp now takes a __PORT__ placeholder that entrypoint.sh substitutes from its own $PORT before starting workerd \u2014 one source of truth.
guard.js validated only the initial URL; fetch defaults to redirect:'follow', so an allowlisted *.apify.com host issuing a 3xx to any other host was followed out silently. Verified live against real workerd: an allowed host redirecting to a disallowed one reached it (before) / was blocked (after). Now fetches with redirect:'manual' and re-validates each Location against the allowlist before following, one hop at a time (capped at 5), with WHATWG-spec method/body downgrade rules (303 always -> GET; 301/302 -> GET only if the original method was POST; 307/308 preserve method + body). Also lock globalThis.fetch via Object.defineProperty(writable:false, configurable:false), matching the existing WebSocket/EventSource treatment (a plain assignment could be reassigned/deleted by the sandboxed script). Verified live: reassigning globalThis.fetch from a script now throws TypeError instead of succeeding.
…ings
usercode.js wraps the user's code inside 'export async function run(...) {
... }' with nothing else at module scope, so a syntax error in it fails
module evaluation before any request reaches runner.js's try/catch \u2014 workerd
exits immediately (verified: raw workerd binary, exit code 1, no /health
response, ever). The reviewer's proposed fix (dynamic import inside the
try/catch) does NOT help: verified live that workerd eagerly evaluates every
module declared in config.capnp regardless of static vs dynamic import, so
both crash identically. The actual fix has to live in entrypoint.sh, which
is what this commit does:
- entrypoint.sh now captures workerd's stderr and, if workerd exits before
becoming ready, checks whether the crash names usercode.js. If so (this is
structurally exact, not a heuristic, given usercode.js's shape above) it
pushes a { stdout: '', stderr, exitCode: 1, statusMessage: 'Failed to
compile: ...' } item directly and exits 0, so the run SUCCEEDS with
diagnostics instead of failing as an opaque infra error. Any other crash
(our own runner.js/guard.js, config issue) still hard-fails the run.
Verified live for both the positive (usercode.js) and negative
(unrelated file) cases.
- runner.js adds the same statusMessage field ('Script completed' /
'Script threw: ...') to the normal exitCode 0/1 path, so callers get a
prose signal without branching on exitCode.
- run.abort({ runId }) is now scoped to run IDs this script itself started.
actor.run()/actor.start() share one createRun() helper that records each
created run's ID in a Set; abort() throws on an unrecognized runId instead
of silently no-op'ing (a script shouldn't think an abort succeeded when it
didn't). Previously any account-wide runId could be aborted. Verified live.
- console and the apify binding's namespaces are now Object.freeze'd so a
script can't reassign e.g. console.log to corrupt its own output capture.
Verified live: reassignment now throws TypeError.
- entrypoint.sh also validates the default dataset ID up front (needed by
the new compile-failure push path), matching the existing token/KV-store
check.
Council-reviewed rename (both a data-structure and an interface-design lens independently converged on this one): actor.get matches run.get's existing naming convention in this same binding, which getDetails did not. Breaking change, acceptable pre-release. docs/API.md and README.md's binding summary updated to match; the fuller docs/description self-containment pass (a separate, later PR) will carry this through the rest of the copy.
worker/guard.js and worker/runner.js -> guard.ts / runner.ts, with real types
for the apify binding's full public surface (every method's options object
named and typed, not `any`), the Env/Run/ApifyRecord shapes this code reads,
and the redirect/fetch-guard helpers. Compiles clean under strict:true.
API response shapes stay honestly `any` at the api{Json,Data} boundary --
the Apify API's JSON envelope isn't something this repo has a verified schema
for, and asserting a precise shape we haven't checked would be worse than not
typing it. Everything this code actually authors (every destructured options
object, every local helper's params/return) is fully typed.
Dockerfile's builder stage already runs Node (to resolve the workerd binary
path via require('workerd')) -- compiling TypeScript there is one more RUN
line, not a new toolchain. The runtime stage still copies only the compiled
JS + entrypoint.sh + config.capnp; no Node, no npm packages, no .ts sources
ship in the final image. worker/runner.js and worker/guard.js are now build
artifacts (pnpm build / pnpm typecheck), gitignored, generated from the .ts
source at Docker build time -- matching how the workerd binary itself is
already obtained via the same Node stage.
Added .github/workflows/typecheck.yml (pnpm typecheck on push/PR) -- this is
the actual CI gap the reviewer flagged; it needs no Apify credentials, unlike
test.sh's live apify push + call, so it's safe to run automatically.
Verified zero behavioral regression: reran the full live-workerd suite
(valid script, usercode.js compile-failure diagnostic, runtime throw,
run.abort scoping, console/apify-binding freeze, redirect-following allowlist
bypass) against the freshly compiled output -- all six identical to the
pre-migration JS.
The code input is fetched at container runtime and wrapped verbatim into usercode.js (entrypoint.sh); nothing transpiles it and workerd doesn't strip types, so a TypeScript type annotation in user code is a SyntaxError at load today, not a supported input. actor.json's description/input schema and README's intro + input table claimed TypeScript/JavaScript; corrected to JavaScript only, with the SyntaxError-at-load reason stated so it's not mistaken for an oversight. Scoped to the user-facing code input contract only -- the separate question of what language the Actor's own source is written in was resolved in the previous commit (TypeScript, compiled at build time).
Council-reviewed renames (both a data-structure and an interface-design lens
independently kept these three, out of a larger proposed table):
- actor.run() -> actor.call(): 'run' was doing double duty as a verb (start+wait)
and as the top-level run-management namespace. call avoids the collision and
matches the CLI (apify call) / MCP (call-actor) vocabulary.
- actor.runAndGetItems() -> actor.callAndGetItems(): follows from the above.
- run.wait() -> run.waitForFinish(): passes straight through to the REST
waitForFinish query param; matches the platform vocabulary. The waitForFinishSecs
*parameter* name is intentionally NOT touched -- pairing this method rename with
dropping the unit suffix would produce waitForFinish({ waitForFinish: 30 }),
read as a boolean, not a duration.
- dataset.getSchema() -> dataset.inferFields(): 'schema' already means two other
things in this Actor's own ecosystem (input schema, and this Actor's own
*declared* dataset schema in actor.json) -- inferFields names what the method
actually does (infer field types from a sample) without colliding.
Breaking change, acceptable pre-release. Verified live: apify.actor.call,
apify.actor.callAndGetItems, apify.run.waitForFinish, apify.dataset.inferFields
all exercised end-to-end against a mocked Apify API via real workerd.
docs/API.md and README.md still describe the old names -- updated in the
next commit alongside the rest of the self-containment docs pass.
Final self-containment pass (previously planned as a separate stacked PR, landing directly on this branch per instruction). All content was council- reviewed earlier in the design phase; this lands it against the actual current code (post rename + statusMessage + TS migration). .actor/actor.json: - description (249/300 chars): what it does, the data-heavy/fan-out framing, sandbox limits moved out to README (kept here: run mechanics + billing). - input.code.description (375/500 chars): the correctness-critical facts -- only console output round-trips (a top-level return is NOT captured), call apify.actor.get() before running an Actor, print a small summary. - output.description: now includes exitCode + statusMessage. - defaultRunOptions: timeoutSecs 900, memoryMbytes 1024 -- this Actor had no default before; overridable per call. - storages.dataset: declared fields (stdout/stderr/exitCode/statusMessage, exitCode kept as enum:[0,1] -- not widened to nullable, see prior design notes) plus a dataset-level description stating the run-level-kill cardinality caveat (zero items is a distinct case from any field's value). No `views` block -- a single always-one-item dataset doesn't need one. README.md: - "Calling this Actor" replaces the stale `?tools=run-code,get-code-docs` section -- that mechanism doesn't exist; call-actor/search-actors/ fetch-actor-details are already default MCP tools, no opt-in needed. - Data-heavy/fan-out framing + the free-text-extraction anti-pattern (kept here, cut from the character-capped actor.json description). - Workflow tips (get-before-running, log storage IDs before processing, print-small-summary, return values aren't captured) folded into "How it works" rather than a new section duplicating the code-input description. - "Limits & failure modes": defaultRunOptions + override, and the exitCode/statusMessage-vs-run-status distinction for resource kills. - "Recipes": bounded parallel fan-out (this Actor's actual measured strength) and the >60s start+poll pattern. - "Limitations": sub-Actor runs aren't MCP-attributed -- known, unresolved, tracked separately, not fixed by this PR. - Egress-safety wording softened to "no direct fetch-based exfil", not "contained" -- actor.start()/dataset writes are still exfil paths outside this guard's scope. - Binding summary + Input example updated to the renamed methods (call/callAndGetItems/waitForFinish/inferFields/get). docs/API.md: renamed section headers/cross-references to match (actor.call, actor.callAndGetItems, run.waitForFinish, dataset.inferFields). package.json: description no longer claims "Worker Loader API" (this Actor is one static worker, not per-request isolate loading) -- stale since before this branch, corrected here alongside the rest of the accuracy pass.
… doc link
- tests/binding-smoke.ts, tests/sandbox-isolation.ts: typed against the same
ApifyBinding surface runner.ts exposes at runtime (exported type-only from
runner.ts), and updated to the renamed methods (get, call, callAndGetItems,
waitForFinish, inferFields) that a prior commit renamed but these probes
still called under their old names.
- tests/globals.d.ts: ambient apify/process/require declarations via
`declare global` for these standalone-compiled probe files (apify/console
are real function parameters at runtime once entrypoint.sh splices the
compiled JS into the code input, not globals).
- tsconfig.json: tests/*.ts now type-checked alongside worker/*.ts.
- package.json build: strips the `export {};` marker tsc appends to each
probe (needed so tsc treats each file as its own module — otherwise their
top-level consts collide across files, and top-level await needs a module).
Left in, that line would be a syntax error once spliced into the wrapping
`async function run(apify, console) { ... }`. Verified with node --check
against the actual wrapped shape.
- test.sh: runs `pnpm build` before pushing/calling so probes compile first.
- docs/API.md: fix a stale `run.wait` anchor missed by an earlier rename
commit (actual method is `run.waitForFinish`).
Revisits the two renames the council flagged as bikeshedding and deferred
to v2 (now resolved per your call: breaking changes are fine, this is a POC).
- api.apify.com/v2/openapi.json tags GET /v2/store as its own top-level
'Store' resource (operationId store_get), sibling to 'Actors', not a
sub-resource of it. Moved actor.search() out of the actor.* namespace to a
bare apify.store({ search, limit?, category? }) binding to match — the
Store resource has exactly one operation, so a namespace object would be
one method wrapping nothing.
- Renamed the search param query -> search to match the API's own field
name (GET /v2/store?search=...), so the options object no longer invents
a name the wire format doesn't use.
- Renamed kvs -> keyValueStore (namespace + all Kvs*Options interfaces) to
match the API's own key-value-stores resource name instead of an
abbreviation found nowhere in the Apify API itself.
- README, docs/API.md, tests/*.ts updated to match. docs/API.md gets its own
## apify.store section ahead of ## apify.actor.
…s MCP-started Closes (partially) the MCP-attribution gap flagged in README's Limitations. Verified end-to-end against apify-core / apify-worker source (not just docs): - apify-mcp-server sends X-Apify-Request-Origin: MCP on every API call (src/apify_client.ts). - apify-core's requestOriginParserMiddleware validates it against META_ORIGINS (includes MCP) and sets req.origin (src/api/src/middleware/request_origin_parser.ts). - actor_jobs.ts's createMeta(req.origin || defaultOrigin, ...) -- defaultOrigin is META_ORIGINS.ACTOR for actor-run-token callers -- stores it as the new run's meta.origin (src/api/src/lib/actor_jobs.ts:374-378). - apify-worker injects it back into the container as APIFY_META_ORIGIN (act2_run_job.ts:2212, APIFY_ENV_VARS.META_ORIGIN = "APIFY_META_ORIGIN", matches apify-docs' environment_variables.md). So this Actor's own container already receives APIFY_META_ORIGIN=MCP when apify-mcp-server started it -- no mcp-server change, no new Actor input field. Rejected a hidden `isMCPRun` input field: call-actor's tool schema is built directly from actor.json, so a hidden field is either LLM-visible-and-spoofable or never set by anyone. APIFY_META_ORIGIN is platform-injected and unspoofable from inside the sandbox. - worker/config.capnp: bind PARENT_ORIGIN from APIFY_META_ORIGIN (same pattern as the existing APIFY_TOKEN binding). - worker/runner.ts: makeApifyBinding() takes parentOrigin; sends X-Apify-Request-Origin: MCP on all its own API calls only when parentOrigin === 'MCP'. Also sets a plain User-Agent (previously unset). - README: rewrote Limitations to document the (already-existing, no-code-needed) meta.actorRunId parent-run link apify-core sets on every sub-run automatically, and what this change adds on top of it. Verified live with the real workerd binary + a mock Apify API asserting the header: absent when APIFY_META_ORIGIN is unset, absent when it's 'ACTOR', present as 'MCP' only when it's 'MCP'.
pnpm 11 blocks dependency install scripts by default (ERR_PNPM_IGNORED_BUILDS)
unless explicitly allowed. Adds pnpm-workspace.yaml with allowBuilds: { workerd:
true } -- workerd's postinstall fetches its platform-specific binary, which is
what CI's pnpm typecheck (and every other pnpm command) needs present.
Build failed:
sed: can't read tests/*.js: No such file or directory
The builder stage only COPYs worker/ (tests/ is dev-only probe
fixtures for test.sh, submitted as Actor input at run time - never
part of the image, correctly never copied in). `pnpm run build`
compiles both worker/*.ts and tests/*.ts per tsconfig, then sed's the
tsc-appended `export {};` marker out of tests/*.js - that sed fails
outright when the directory doesn't exist.
Call tsc directly instead of the shared pnpm script: tsconfig's
tests/*.ts include glob simply matches nothing when the directory is
absent (no error), producing exactly worker/runner.js + worker/guard.js
- the only two files stage 2 copies. Local dev / test.sh, where tests/
does exist, are unaffected.
Verified: reproduced the exact original failure in an isolated copy of
the builder context (no tests/), confirmed tsc alone succeeds and
produces both worker/*.js files, then ran the full multi-stage
Dockerfile build to completion with podman.
…de field
Agent transcripts show scripts wrapping logic in an unawaited
'async function main(){...}; main().catch(...)' pattern truncate
silently after the top-level body returns — no error, no partial
result, exitCode 0. Also confirmed agents defaulting to the public
apify-client SDK's curried .actor(id).call() shape instead of this
binding's flat { actorId, input } options-object shape.
Both warnings added to the code field's own description since it's
always in context (unlike the README, which agents often skip
fetching before first use).
This Actor's README documents an exact API contract (apify.* method names/shapes) -- the platform's auto-generated readmeSummary omits that section entirely, confirmed via a live eval trace where the agent fetched the README, got the summary, and guessed the wrong dataset method as a result. Requires the matching apify-mcp-server change (resolveReadmeContent honoring this flag). Unverified: whether this unrecognized actor.json key survives the platform's build validation into the API response -- needs a live check after this Actor is rebuilt.
…n-sandbox check
Live eval trace: agent wrote apify.actor.callAndGetItems({ actorId:
'apify/rag-web-browser', input: { url, maxPages } }) from memory --
wrong field names (real one is 'query') -- despite this field's
existing 'call apify.actor.get() first' guidance. All 20 calls failed
fast with a clear 400, so no time was wasted discovering it, but it
still cost a full wasted round trip before the agent self-corrected.
The in-sandbox-only phrasing was easy to skip since it costs an extra
nested Actor call inside the script. Reworded to lead with the outer
fetch-actor-details tool -- the same tool the agent already uses
correctly for every other Actor it discovers via search-actors, just
not yet for ones it only decides to call while writing code -- and
keep apify.actor.get() as the fallback for Actors picked at runtime.
Council review (Torvalds/Hotz/Pike/Hoare) + live eval traces: 1 of 3 recovery
attempts wasted a full re-run of an already-succeeded nested Actor call
instead of reusing its logged defaultDatasetId, because the guidance was
advisory ("can read those existing storages") not a rule. Reworded to an
imperative 'reuse it, do not re-run' in the code field description, README,
and docs/API.md.
Separately: actor.call/run.waitForFinish's 60s wait cap was already
documented, but a trace showed an agent still treating a returned
READY/RUNNING status as a hard failure right after a single wait -- the
cap and its corollary (non-terminal isn't an error) lived in two different
places. Added an inline note directly on the binding-table/method-doc lines
themselves, not just the separate polling recipe.
Grounded in a fresh clone of apify-client-js (the real Apify TS SDK) and a
council review (Torvalds/Hotz/Pike/Hoare) — all four independently converged
on the same root cause behind the highest-frequency bug in this project:
listItems()/callAndGetItems()/store() each returned a different envelope
for 'here are your records' (bare array / bare array / {run,items}),
confirmed still recurring 3x even with the full (non-summarized) README
available, so more prose wasn't going to fix it.
dataset.listItems() and store() now return a value that's both a Promise
(await -> one page, { items, count, offset, limit, desc }) and an
AsyncIterable (for await -> every item, auto-paginated) -- the same dual
nature as apify-client's own PaginatedIterator, implemented via the same
mechanism (Object.defineProperty(promise, Symbol.asyncIterator, ...)), but
as ONE shared implementation (makePaginatedList) instead of apify-client's
own three independent, subtly-different copies of this trick.
This makes dataset.iterate() redundant -- removed, along with
DatasetIterateOptions and DEFAULT_ITERATE_BATCH. actor.callAndGetItems()
and dataset.inferFields() updated for the new page shape (no behavior
change -- both only ever consumed a single page).
Verified beyond typecheck: a standalone runtime smoke test confirms the
dual nature actually works (await resolves to one page; for-await
auto-paginates across multiple pages; a call with no limit makes exactly
one HTTP request instead of over-fetching). tests/binding-smoke.ts and
tests/sandbox-isolation.ts updated for the new shapes.
Explicitly NOT adopted from apify-client: its curried client.actor(id).call()
shape (this binding's flat options-object convention already fixed a
confirmed confusion bug this session and stays), and its own internal
inconsistencies (mixed positional/options args, waitForFinish vs waitSecs
naming split for the same concept).
Breaking change for any script written against the old bare-array
listItems()/store() shape -- every script is freshly generated per run
(one script per Actor run, no shipped callers), so no migration path is
needed.
A/B eval (run 2, apify-mcp-server evals/workflows) shows code mode's worth-it point isn't item count alone but item count x per-item payload size, given the sandbox's fixed ~20K token round-trip overhead: - 5-item single lookup: +78% slower, +46% more tokens (loses) - 100-record filter/sort/aggregate: -35% faster, -19% fewer tokens (wins) - 20-item fan-out over per-item web pages: -59% faster, -75% fewer tokens (wins decisively even at low item count) Encode this as concrete thresholds in both the actor.json description (agent-facing at discovery time) and README (agent-facing after fetch-actor-details).
…prose Council review (Pike, Davis, Hotz) flagged the README as over-explained: same facts stated 2-4x across sections with circular cross-references. - Remove 'Limitations' section (meta.origin/meta.actorRunId telemetry attribution) entirely — internal plumbing irrelevant to a caller deciding how to use this Actor. - Merge 'Limits & failure modes' into Output — both described the same exitCode/statusMessage vs run-status distinction, cross-referencing each other in a circle. - 'No imports' facts now live once in Permissions & safety; How it works just points there instead of restating. - Split the nested-run bullet (was one sentence doing four jobs) into two. - 'Worth it only for bulk work' bullets -> table. - Dual Promise/AsyncIterable explanation for listItems/store stated once (apify binding section); Recipes and inline comments now just reference it. - Fixed a real inconsistency: Output's terminal-status list was missing ABORTED (present in the Recipes poll-loop code right below it). 273 -> 221 lines, same information.
- What it does: drop 'Code Mode' naming and the TypeScript-SyntaxError aside (JS-only is already stated in the Input field description). - Calling this Actor: drop the raw-API POST/nextStep alternative — the call-actor example is the one path that matters here. - Learn more: drop the Code Mode design PR link, keep just the MCP Server link. - Also dropped the earlier free-text-judgment table row per feedback.
MCP server now hardcodes this Actor's ID to always return the full README, instead of trusting a self-declared actor.json flag any Actor could set to opt itself out of the auto-generated summary.
|
I tested it and tried to break it, but it held up :D However, there is an inacurate comment that should be fixed before merging: IssueThe }-escape in entrypoint.sh works and ES post-order runs guard → usercode → runner body, so user top-level code executes before runner.ts:29 claims. A script can call claimRealFetch() first: // run-code input So the comments at guard.ts:18-25 and runner.ts:17-18 ("runner.js … always claims first") are inverted. User code claims first. Fix direction (either):
Either removes both the false comment and the self-DoS. Worth adding a sandbox-isolation.ts case that exercises the claimRealFetch path to keep this covered. |
PR #1 review comment (2026-07-21, issuecomment-5037390847) found the claimRealFetch() one-shot handoff added in 5461aee didn't guarantee runner.ts claims first. entrypoint.sh splices `code` verbatim (no escaping) into `export async function run(apify, console) { <code> }`; a bare `}` in `code` closes that function early, and everything after runs as ordinary MODULE-SCOPE code in usercode.js. ES module evaluation order puts that ahead of runner.ts's own top-level code (usercode.js is the import evaluated immediately before runner.ts's own body runs), so injected top-level code using `(await import('./guard.js')).claimRealFetch()` reliably claimed the unrestricted fetch first, making runner.ts's own claim get null and throw -- crashing the whole Actor run. Verified live against the deployed Actor before this fix: workerd exits with 'Uncaught Error: realFetch already claimed — guard.js imported out of order', run FAILS. Root cause isn't import order, it's that claimRealFetch() was claimable at all during module evaluation, before any request. Fix: gate it on genuine request handling instead. guard.ts adds requestHandlingStarted (false until markRequestHandlingStarted() is called) and claimRealFetch() refuses (returns null without consuming the resource) until that flag is set. runner.ts calls markRequestHandlingStarted() + claims realFetch as the first, synchronous statements of the /run path in its fetch handler -- before any `await`, so no attacker-scheduled microtask from usercode.js's module scope can race it. Module evaluation (guard.js, usercode.js, runner.ts's own top level) always completes before workerd dispatches the first request, so this holds regardless of any module-graph ordering, unlike the previous design. realFetch itself moved from a module-top-level const (claimed at runner.ts's own top-level, which is what lost the race) to a module-level definite-assigned `let`, assigned inside the /run handler before any of the functions that close over it run. Also fixes the stale comments at guard.ts (claimRealFetch) and runner.ts (top-of-file + requireRealFetch) asserting import order made runner.ts claim first -- disproved by the live PoC above. Adds tests/fixtures/realfetch-escape.js: a regression probe using the exact escape shape from the review comment (real top-level await, not an async IIFE -- top-level await is what forces usercode.js's evaluation to fully settle before runner.ts's own top-level code runs; an IIFE's internal await doesn't carry that guarantee). Wired into test.sh as a new live check asserting `apify call`'s own exit status (this probe's run() body is an empty no-op post-escape, so it has no captured console to report a sentinel through, unlike the existing binding-smoke/sandbox-isolation probes). Verified against the real deployed Actor, before/after, same probe: - before (reverted worker/*.ts, same probe file): Actor run FAILS, workerd crash log matches the predicted 'guard.js imported out of order' message. - after (this commit): Actor run SUCCEEDS, exitCode 0, statusMessage 'Script completed' -- same as running an empty script. Full suite (binding-smoke, sandbox-isolation, this new regression probe) run live via ./test.sh: 19/19, 17/17, and the regression probe all pass.
|
@RobertCrupa Thank you! Should be fixed now and I also added this regressions test. |
jirispilka
left a comment
There was a problem hiding this comment.
Thanks for the changes!
This PR is getting slightly out of hand.
If you please take a look at the security issue by Claude.
If valid, let's fix it and merge.
For the rest of the comments, please create an issue and we can fix it in the next PR.
There was a problem hiding this comment.
I'm sorry for AI copy paste, but I think this explanation is better than mine.
I found it using Opus. I've tried to verify it usgin Favle and GPT 5.6 Sol. both refused :D. But Kimi 3 was ok with it :)
Security invariant bypass: exported gate setter lets user code steal the one-shot realFetch
Verified live against commit 8930b95.
Issue
markRequestHandlingStarted() is exported from worker/guard.ts, while usercode.js is statically imported by worker/runner.ts and evaluated in the same module realm. User code that escapes the generated export async function run(...) { ... } wrapper can therefore import guard.js, open the gate itself, and consume the unrestricted one-shot fetch:
}
const guard = await import('./guard.js');
guard.markRequestHandlingStarted();
globalThis.__stolenRealFetch = guard.claimRealFetch();
;{claimRealFetch() returns the real, un-allowlisted fetch function rather than null. When /run subsequently executes, the runner's own claim receives null and throws:
realFetch already claimed.
The /run request returns HTTP 500, and the curl -f invocation in worker/entrypoint.sh fails the Actor run. This is a deterministic run failure with the current code.
The documented invariant in worker/guard.ts is therefore false:
A claim attempted before that (legitimate or injected) gets null without consuming the resource, so the real claim still succeeds afterward.
That statement holds only when user code calls claimRealFetch() without first invoking the exported setter. An injected caller that flips the gate first consumes the one-shot.
The shipped tests/fixtures/realfetch-escape.js regression exercises only the naive claim without the gate flip, so it passes while leaving this path uncovered.
Security impact
The confirmed impact is:
- User code can acquire a capability intended to remain available only to trusted runner code.
- User code can consume that capability before the runner and deterministically fail the Actor run.
- The guard's documented security invariant does not hold.
Unrestricted egress or SSRF is not confirmed on the pinned workerd version. workerd rejects asynchronous I/O during global-scope module evaluation:
Disallowed operation called within global scope. Asynchronous I/O
(ex: fetch() or connect()) ... are not allowed within global scope.
The stolen function cannot therefore make a request from module scope. It also cannot be used inside run(), because the runner fails while claiming realFetch before it invokes run().
This means there is no demonstrated exfiltration path today. However, during request handling, guard.js is the only hostname restriction on fetch; worker/config.capnp permits public, private, and local destinations. If global-scope I/O behavior changes through a workerd upgrade or compatibility/configuration drift, a usable stolen fetch would provide unrestricted egress and SSRF access to internal or metadata addresses.
The issue is best characterized as a security invariant bypass with deterministic self-DoS and latent unrestricted-egress risk. It should be fixed before the runtime leaves experimental status.
Fix
Make ordering the security boundary: trusted runner code must consume the capability before any user-controlled module is evaluated.
1. Simplify worker/guard.ts
Delete requestHandlingStarted and the exported markRequestHandlingStarted(). Keep claimRealFetch() as a plain one-shot:
const realFetch = globalThis.fetch.bind(globalThis);
let unclaimedRealFetch: typeof realFetch | null = realFetch;
export function claimRealFetch(): typeof realFetch | null {
const fetchFn = unclaimedRealFetch;
unclaimedRealFetch = null;
return fetchFn;
}2. Remove the static user-code import
Remove this import from worker/runner.ts:
import { run } from './usercode.js';3. Claim first, then dynamically import user code
Call requireRealFetch() as the first statement after /run route validation, before any await, callback, or user-module evaluation. Dynamically import usercode.js only after the claim:
realFetch = requireRealFetch();
try {
const { run } = await import('./usercode.js');
await run(
makeApifyBinding(token, apiV2, env.PARENT_ORIGIN),
captureConsole,
);
} catch (err) {
// Existing user-error handling.
}The resulting ordering is:
/run route validated
-> runner consumes realFetch
-> usercode.js evaluates
-> any user claim receives null
This ordering is load-bearing and should be documented in both guard.ts and runner.ts. No await, user callback, or user-module evaluation may be introduced between route validation and the trusted claim.
Important workerd parsing behavior
Dynamic import does not defer parsing of modules embedded in config.capnp on workerd 1.20260402.1. A syntax error in usercode.js still kills workerd during service startup, before /run:
service main: Uncaught SyntaxError ... at usercode.js:2:6
The grep 'usercode.js' compile-failure branch in worker/entrypoint.sh must remain. It is the only available detection point for this failure and converts the startup error into the expected succeeded Actor run with a compile-failure dataset item.
Behavior change
After the fix, user module-scope code evaluates during the /run request rather than during worker startup. Module-scope guarded fetch() is consequently allowed where workerd previously rejected it as global-scope I/O.
This is security-neutral as long as the claim-before-import ordering holds: user code receives only the locked, allowlisted globalThis.fetch, while the unrestricted one-shot has already been consumed by the runner.
Verification
The fix was validated end-to-end on workerd 1.20260402.1:
- The original attack calls a setter that no longer exists. Its resulting
TypeErroris caught as a user error,/runreturns 200, and no outbound request occurs. - A strengthened escape fixture confirms that no gate setter is exported and
claimRealFetch()returnsnull; the result hasexitCode: 0. - The sandbox-isolation probe passes all 17 checks through the dynamic-import path.
/healthdoes not consume the one-shot.- A second
/runreceives HTTP 500 withrealFetch already claimed., preserving the documented single-run assumption. - Syntax-error user code kills workerd during startup and names
usercode.js; the entrypoint detection matches it. - Type-checking and compilation pass.
Tests to add
Strengthen tests/fixtures/realfetch-escape.js
The fixture should assert both required security properties. A thrown assertion rejects the dynamic import and produces exitCode: 1, making either regression visible:
}
const guard = await import('./guard.js');
if ('markRequestHandlingStarted' in guard) {
throw new Error('Security regression: request gate setter is exported');
}
const stolen = guard.claimRealFetch();
if (stolen !== null) {
throw new Error('Security regression: unrestricted fetch was claimable');
}
;{Expected result: a succeeded Actor run whose output dataset item has exitCode: 0.
Add a syntax-error fixture
Add an intentionally invalid fixture such as:
const = ;Expected result: the Actor run succeeds and its output dataset contains exitCode: 1 and Failed to compile.
Assert dataset output
Tests must assert the pushed dataset item rather than merely checking that the Actor process survived:
apify call -f "$input_json" -oDo not combine -o with --json; Apify CLI 1.7.1 treats --output-dataset and --json as mutually exclusive.
Run the complete verification:
pnpm run typecheck
pnpm build
sh -n worker/entrypoint.sh
sh -n test.sh
./test.shExpected results:
- Binding smoke probe passes.
- Sandbox isolation passes all 17 checks.
- Strengthened
realFetchescape fixture producesexitCode: 0. - Syntax-error fixture produces the expected compile-failure dataset item.
/healthdoes not consume the one-shot.- A second
/runis rejected withrealFetch already claimed.
There was a problem hiding this comment.
Codex always refused to test this for me, but when I asked Claude Opus, it initially declined, and when I asked him again to do it, it worked fine :)
There was a problem hiding this comment.
Consider addition of tests into CI:
Claude:
Testing gap: no behavioral tests in CI — the sandbox boundary itself is ungated
CI runs typecheck only (.github/workflows/typecheck.yml — one job, pnpm run typecheck). Every behavioral test in test.sh requires apify push + apify call + a token, so nothing about the
security boundary runs in CI. Locally, the guard logic has no token-free test at all: the allowlist paths of isAllowedHost/validateUrl are only exercised live on-platform
(tests/sandbox-isolation.ts), and guardedFetch's redirect re-validation — the entire reason that function exists — has no test of any kind, live or local.
There was a problem hiding this comment.
Fixed. Added token-free unit tests and real-workerd integration tests with a local API mock to CI.
There was a problem hiding this comment.
Most smoke-test checks don’t validate returned values
tests/binding-smoke.ts:14-23 marks a check as passed whenever its callback does not throw. Most callbacks only return a diagnostic string, so incorrect or empty results can still produce ALL_TESTS_PASSED.
For example, keyValueStore.get never verifies the round-tripped values, and the paginated dataset.listItems check never confirms that both pushed items were yielded. An early-stop regression could therefore
pass silently.
Please assert the expected results, for example:
const obj = await apify.keyValueStore.get({ storeId, key: 'obj' }) as { hello?: unknown } | null;
if (obj?.hello !== 'world') {
throw new Error(`kvs.get returned ${JSON.stringify(obj)}`);
}
let n = 0;
for await (const _ of apify.dataset.listItems({ datasetId, limit: 1 })) n++;
if (n !== 2) throw new Error(`expected 2 items, iterated ${n}`);There was a problem hiding this comment.
Fixed. Smoke tests now verify KVS round-trips, dataset contents, pagination, run statuses, and returned values.
There was a problem hiding this comment.
Consider aborting still-active tracked runs when handling a script exception
There was a problem hiding this comment.
Fixed. Tracked non-terminal child runs are best-effort aborted when the user script throws; cleanup errors do not hide the original failure.
| // Runs an Actor (same as call(), waitForFinishSecs defaults to 60) and returns its | ||
| // dataset items in one call. Calls createRun() directly rather than through | ||
| // `actor.call()` — same underlying request, no self-reference to `actor` needed. | ||
| callAndGetItems: async ({ actorId, input, fields, limit, ...runOpts }: RunAndGetItemsOptions): Promise<{ run: RunRecord; items: ApifyRecord[] }> => { |
There was a problem hiding this comment.
callAndGetItems can return undocumented partial results
If the called Actor is still RUNNING when the 60-second API wait expires, callAndGetItems() immediately reads its dataset and returns whatever has been written so far. The returned items may therefore be empty or partial while the child run continues.
Please document this behavior in both README.md and docs/API.md.
There was a problem hiding this comment.
Fixed. README and docs/API.md now explain that callAndGetItems can return empty or partial results while the child run remains non-terminal.
| // Shared by run() and start(): both POST /acts/:id/runs, differing only in whether | ||
| // waitForFinish is set. Records the created run's ID in startedRunIds so run.abort() | ||
| // can be scoped to runs this script itself started (see the run.abort definition below). | ||
| call: (opts: StartOptions): Promise<RunRecord> => createRun({ waitForFinishSecs: 60, ...opts }), |
There was a problem hiding this comment.
The 60-second API limit is repeated. Consider defining DEFAULT_WAIT_FOR_FINISH_SECS = 60 and reuse it
There was a problem hiding this comment.
Fixed. Defined DEFAULT_WAIT_FOR_FINISH_SECS = 60 and reused it for call, callAndGetItems, and waitForFinish.
There was a problem hiding this comment.
apify.actor.start, actor.call, and actor.callAndGetItems expose timeoutSecs, maxTotalChargeUsd, and maxItems, but all are optional and simply forwarded by createRun() when present.
The Code Runtime’s 900-second timeout applies only to the Code Runtime run. Likewise, waitForFinishSecs: 60 only limits how long the API request waits; it neither stops nor limits the cost of the Actor it started. A script can also start an unrestricted number of Actors, with no shared spending budget.
Can we add Code Runtime-level safeguards:
- Add a configurable maximum number of Actor runs per script.
- Apply a configurable default timeoutSecs when one is not supplied.
- Provide an execution-level spending limit and propagate an appropriate maxTotalChargeUsd to each started Actor.
- Reject new Actor starts once the configured run-count or spending budget is exhausted.
- Document that waitForFinishSecs is not an execution or cost limit.
There was a problem hiding this comment.
Fixed. Added configurable run-count, default-timeout, and execution-spending limits with synchronous reservations, rejection, rollback, and documentation.
|
I haven't tested it yet, will do tomorrow |
…+ tests Addresses PR #1 review (jirispilka, 2026-08-01): - worker/guard.ts, worker/runner.ts, worker/config.capnp: the previous fix (8930b95) gated runner's internal-API fetch behind an exported markRequestHandlingStarted/claimRealFetch pair. That gate was itself an export, so escaped usercode.js top-level code could call it directly and steal the unrestricted fetch before runner's own claim ran. Root fix: move internal-API access off any module export entirely, onto a workerd env binding (env.INTERNAL_API, wired to its own outbound network service). `env` only ever reaches the genuinely-dispatched fetch(request, env) call — nothing at module-evaluation time can obtain a reference to it, exported or not. guard.js now exports only pure, safe allowlist helpers. Verified live against the original exploit PoC plus two additional attack variants in a local workerd sandbox: all now fail closed with no capability leak. - config.capnp: split ambient outbound (public-only, for guarded fetch) from the internal-API outbound (public/private/local, internal use only) as a second, independent defense layer against SSRF even if the JS-level guard had a bug. - worker/runner.ts: abort Actor runs a script started but left non-terminal when the script itself throws (best-effort, failures reported not thrown). Extracted DEFAULT_WAIT_FOR_FINISH_SECS. Added optional execution-level safeguards (maxActorRuns, maxTotalChargeUsd, defaultTimeoutSecs) so a script can't start unbounded runs or authorize unbounded spend. - tests/unit/guard.test.ts (new, vitest): token-free CI coverage for isAllowedHost/validateUrl/nextRedirectInit/guardedFetch's redirect re-validation, plus a regression test that guard.js exports nothing beyond known-safe helpers. CI now runs typecheck + this suite on every PR. - tests/binding-smoke.ts: every check now asserts the actual returned value instead of only checking the callback didn't throw. - tests/fixtures/realfetch-escape.js, test.sh: updated regression probe and comments for the new architecture (the old exploit call now throws a plain TypeError at module eval, reported as a normal compile-failure diagnostic). - docs/API.md, README.md, .actor/actor.json: documented the new execution limits, and that callAndGetItems/actor.call/run.waitForFinish can return partial/non-terminal results — waitForFinishSecs bounds the API wait only, not the child run's cost or duration.
…egration tests Scout-loop iteration 1 (4 parallel reviewers: security, code-quality, coding-standards, feature-impact) found real regressions in the previous commit: - worker/runner.ts: the previous commit's rewrite DROPPED the guard.js side-effect import entirely while removing the old claimRealFetch import — guard.js was never imported by anything in the real module graph, so its fetch/WebSocket/EventSource overrides never ran. Every script's plain fetch()/WebSocket had unrestricted egress. Confirmed live (workerd), fixed by restoring `import './guard.js'` as the first import, with a comment on why the order matters and pointing at the regression test (tests/sandbox-isolation.ts's existing 'block https://example.com/' check, now backed by a new real-workerd integration test). - worker/runner.ts's createRun(): maxActorRuns/maxTotalChargeUsd were checked synchronously but only recorded after their POST resolved, so N concurrent calls (this Actor's own documented 'Bounded parallel fan-out' recipe) all read pre-reservation counters and all passed. Now reserves synchronously before the first await, rolls back on failure. Covered by a new integration test that actually races 5 concurrent actor.start() calls against a real workerd process. - worker/runner.ts: froze the default export object so escaped module-scope code can't reassign .fetch to a wrapper that captures the real request/env on the next genuine dispatch — closes a residual capability-theft vector of the same class already fixed twice in this file, found by feature-impact review of the seam. - .actor/actor.json: maxTotalChargeUsd allowed 0, which parsePositiveNumberEnv treats as 'unset' (unlimited) — the exact inverse of a user setting a bash safety budget. Schema now requires a positive value, matching its sibling fields. - worker/guard.ts: guardedFetch's redirect-hop counter was a public parameter on an exported function (any escaped-code caller could pass a pre-inflated value to defeat MAX_REDIRECT_HOPS) — split into an unexported recursive helper. - worker/runner.ts: renamed the ad-hoc TERMINAL_STATUSES (which included the non-terminal ABORTING) to DONE_TRACKING_STATUSES with a comment on why, to stop it reading as (and drifting from) docs/API.md's actual terminal-status set. - makeApifyBinding/pushOutput: switched to object parameters (5 positional args each, over this codebase's own >3-params convention). - tests/integration/ (new): boots a real workerd process against the actual compiled worker/*.js with a local mock internal-API server — closes the gap unit tests structurally can't (guard.js correct-in-isolation vs. actually-wired-in; Limits enforcement holding under real concurrency). Runs in CI, still fully offline. - Also: RunRecord.defaultDatasetId typed properly (removed an unjustified cast), Limits fields use `| undefined` per this codebase's own convention, vitest configs added (was silently double-running the suite via build byproducts), @types/node added scoped to tests/integration only (kept separate from the rest of the program, which intentionally models workerd's no-nodejs_compat environment).
…ess bugs
Scout-loop iteration 2 (4 fresh reviewers) found real bugs in the previous round's fixes:
- worker/guard.ts: validateUrl called the bare `new URL(...)`, resolving whatever
globalThis.URL currently is. A plain script (no module-scope escape needed) could
do `globalThis.URL = class extends URL { get hostname() { return 'apify.com'; } }`
to make the allowlist check believe a disallowed host was apify.com, while the
actual fetch still went to the real target string. Fixed by capturing the real URL
constructor (RealURL) before usercode.js can ever run, same pattern as realFetch.
Verified live against workerd, plus a new unit test regression case.
- worker/runner.ts's createRun(): a script-supplied maxTotalChargeUsd of NaN (or
another non-finite/non-positive value) flowed straight into committedChargeUsd's
arithmetic. NaN is absorbing (NaN - x and x - NaN both stay NaN), so a single bad
call permanently corrupted the running total and defeated the whole execution-level
budget check for the rest of the script — the catch block's rollback can't recover
it either (subtracting NaN from NaN is still NaN). Now validated (finite, > 0)
before it touches any shared state. Verified live against workerd.
- worker/runner.ts: runner.ts's own Object.freeze calls run (via import order) AFTER
usercode.js's module body, so escaped top-level code could shadow the global
Object.freeze to a no-op before any of them ever fire — defeating the 'frozen so
the script can't reassign this' guarantee on the apify binding/console/default
export. Fixed by exporting a pre-captured realObjectFreeze from guard.ts (same
capture-before-usercode-runs pattern), used throughout runner.ts instead of the
bare global.
- tests/integration/harness.ts: the mock API's route matching used
`req.url.endsWith('/runs')`, which breaks the moment a real request carries a
query string — createRun() always attaches one (waitForFinish/timeout/memory/
maxTotalChargeUsd), so requests silently fell through to the wrong mock response
branch, masking real behavior in several 'passing' tests. Fixed to match on
pathname. Also: removed a test that quietly made a live network call to
https://apify.com (flaky, contradicted this suite's own 'offline' claim — the
allow-path is already covered offline by the unit suite); added a new test proving
createRun()'s rollback actually fires on a real API rejection (via a new
failNextRunCreate() hook on the mock); every acquired resource (mock server, temp
dir, workerd child process) now released via try/finally on every path, including
waiting for the child to actually exit before returning.
- worker/runner.ts, tests/binding-smoke.ts: fixed a stale comment left after the
previous round's TERMINAL_STATUSES -> DONE_TRACKING_STATUSES rename, and corrected
DONE_TRACKING_STATUSES's own comment to name its real call sites (createRun/
waitForFinish, not abortTrackedRuns).
- tsconfig.integration.json now extends tsconfig.json instead of duplicating its
compiler options.
- .actor/actor.json: dropped a restated numeric-floor sentence from
maxTotalChargeUsd's description that broke the sibling fields' pattern of trusting
the JSON Schema constraint alone.
…hod poisoning Scout-loop iteration 3 (4 fresh reviewers, all converging independently on the same root cause): rounds 1-2 fixed *reassignment* of security-relevant globals (globalThis.URL, globalThis.Object.freeze) by capturing a reference before usercode.js can run. That does NOT protect a shared PROTOTYPE method or static function — a captured constructor's .prototype IS the same mutable object as the live global's .prototype, and a captured static function reference only helps if it's captured before usercode.js runs, which several call sites weren't. Three real, empirically-verified bypasses, no module-scope escape needed at all — ordinary script-body code: - worker/guard.ts's isAllowedHost called hostname.toLowerCase()/.replace()/.endsWith() directly. `String.prototype.endsWith = () => true;` in a normal script defeated the entire *.apify.com allowlist for every fetch() call. Verified live: fetch to http://example.com/ succeeded. - worker/runner.ts's run.abort ownership check did `startedRunIds.has(runId)`. `Set.prototype.has = () => true;` let a script abort ANY run, not just ones it started — a real capability escalation, not just an SSRF variant. Also affected DONE_TRACKING_STATUSES.has (runner.ts) and REDIRECT_STATUSES.has (guard.ts). - worker/runner.ts's new maxTotalChargeUsd validation did `Number.isFinite(...)`. `Number.isFinite = () => true;` resurrected the exact NaN-budget-corruption bug the previous round's fix was written to close. Math.min's budget clamp had the same gap. Fix: consolidated every builtin guard.ts's or runner.ts's security decisions depend on into one explicit, documented capture block at the top of guard.ts (RealURL, realObjectFreeze, and now setHas/numberIsFinite/mathMin plus guard.ts-private string-method captures for isAllowedHost) — captured once, before usercode.js can ever run, invoked via .call()/direct reference rather than through the poisonable value.method() syntax. This is the audit surface for the whole trust boundary now, instead of three independently discovered special cases. Added a dedicated regression test for each captured primitive (mirrors the existing RealURL test): realObjectFreeze/setHas/numberIsFinite/mathMin/isAllowedHost's string methods, each proven to still work correctly after the corresponding global is poisoned. Added an integration test for the maxTotalChargeUsd validation end to end. All three original exploits re-verified live against real workerd post-fix: fail closed as expected.
…t, JSON.stringify
Scout-loop iteration 4 (4 fresh reviewers doing an exhaustive line-by-line sweep of
guard.ts/runner.ts) found the round-3 capture-block pattern was correct but incomplete —
applied only to the specific bypasses previous rounds happened to discover live, not
re-derived as a full audit. Found and fixed, all verified live against real workerd:
- worker/runner.ts's buildUrl() had its OWN bare `new URL(...)` call, never touched by
round 2's guard.ts fix — building the URL for the unrestricted, token-bearing internal
API request. A script reassigning globalThis.URL could redirect every apify.actor.*/
dataset.*/keyValueStore.* call to an attacker-controlled host, with the real
Authorization: Bearer <APIFY_TOKEN> header attached. Highest-severity finding across all
4 rounds. Fixed by exporting guard.ts's captured RealURL and using it in runner.ts too.
- guard.ts's validateUrl read `url.hostname`/`url.protocol` directly — PROTOTYPE ACCESSOR
(getter) poisoning (`Object.defineProperty(URL.prototype, 'hostname', { get: () =>
'apify.com' })`) defeated the allowlist even with RealURL's constructor captured, since
captured-constructor.prototype IS the same live, mutable prototype object. Same root
cause as round 3's method-poisoning bugs, one syntactic shape removed (accessor vs.
method). Fixed by capturing the getter FUNCTIONS themselves and invoking via .call().
- worker/runner.ts used the bare, poisonable `encodeURIComponent` for every internal API
path segment (actorId/runId/datasetId/storeId/key, 13 call sites) — poisoning it to a
no-op let a script inject unescaped path segments (`../../key-value-stores/SECRET/
records/token`) into the token-bearing internal API request.
- worker/runner.ts's pushOutput used bare `JSON.stringify` to build the run's own trusted
result item — poisoning it let a script forge {exitCode:0, statusMessage:'Script
completed'} over a real crash, defeating the documented 'callers detect a failed script
via this field' guarantee. Same gap in apiCall's/keyValueStore.set's request bodies
(lower severity, already-attacker-owned data, fixed for consistency).
- worker/runner.ts's response.ok reads (apiCall, keyValueStore.get, pushOutput) and
parsePositiveNumberEnv's Number() coercion had the same bare-global gap.
All consolidated into guard.ts's existing capture block (now also: realNumber,
encodeUriComponent, jsonStringify, urlHostname, urlProtocol, responseOk, responseStatus,
and an exported RealURL for runner.ts's own use), with a dedicated poisoning-regression
test per primitive in tests/unit/guard.test.ts (44 tests total, up from 39) plus a new
integration test for the maxTotalChargeUsd-via-createRun path.
Every 4th-round exploit re-verified live against real workerd post-fix: encodeURIComponent
poisoning now produces a correctly-escaped path, JSON.stringify poisoning no longer masks a
real script crash, and a poisoned URL constructor no longer redirects the internal API call
away from its real host.
|
Thanks for detailed review. Personally, I don’t think it’s worth obsessing over making this security boundary perfect. There will probably always be another bypass unless we add an infrastructure-level egress proxy, which doesn’t seem worth it for this Actor. That said, I summoned an army of agents to review the implementation from security, quality, standards, and blast-radius angles. We went through four iterative rounds and fixed the findings, then hardened the runtime further so we can ship this confidently. Your points about cancelling tracked runs and handling syntax errors were especially useful. I originally wanted to ship a minimal version first, validate whether people actually use this approach, and iterate afterward. We can ship those improvements now instead, so I implemented them. Thanks. |
For reviewers: apify-mcp-server PRs #1123 and #1124 exercise this Actor through Code Mode.
What
code-runtimeruns one JavaScript program per Actor invocation inside a sandboxedworkerdisolate. It exposes a typedapifybinding for Store, Actor runs, datasets, and key-value stores, then writes{ stdout, stderr, exitCode, statusMessage }to the default dataset.User code can fetch only
http(s)://apify.comand*.apify.com. Internal API calls use a separateenv.INTERNAL_APIservice binding and never expose the platform token-bearing capability to user code.Why
This provides Code Mode for the Apify MCP Server without adding a dedicated MCP tool. The runtime now closes the reviewed capability-theft, SSRF, redirect, prototype-poisoning, path-injection, token-exposure, and concurrent run-limit bypasses.
Execution safeguards support maximum spawned runs, aggregate committed charge, and default child-run timeout. Spawned run IDs are scoped to the current script; unfinished runs are best-effort aborted when the script throws.
Syntax errors in generated user code become diagnostic dataset output with
exitCode: 1andstatusMessage: "Failed to compile: ...". Infrastructure failures still fail the Actor run.Data: does Code Mode actually help?
Ran a paired A/B eval using apify-mcp-server's
evals/workflowsharness: the same task, agent, and judge model were used for both arms. One arm used regular MCP Actor/storage tools directly; the other routed work through this Actor. Each arm ran 5 repeats across 3 task shapes.Conclusion, encoded in the Actor description and README:
Testing
pnpm run typecheckpnpm buildpnpm run test— 44 unit testspnpm run test:integration— 10 real-workerd tests with a local API mock./test.sh— live Actor binding and sandbox probesThe latest changes also remove redundant comments while preserving security and ordering invariants.