Skip to content

feat(security): #503 — refuse compile-time dynamic dispatch on stdlib namespaces#941

Merged
proggeramlug merged 3 commits into
mainfrom
feat/503-refuse-dynamic-stdlib-dispatch
May 18, 2026
Merged

feat(security): #503 — refuse compile-time dynamic dispatch on stdlib namespaces#941
proggeramlug merged 3 commits into
mainfrom
feat/503-refuse-dynamic-stdlib-dispatch

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Closes #503.

Summary

  • Compile-time HIR pass refuses obj[runtimeVar]() and obj[atob(...)]() shapes when obj resolves to a Node-core stdlib namespace (process, fs, crypto, child_process, net, os, path, http, https, http2, stream, url, util, events, dns, tls, querystring, zlib, async_hooks, readline, string_decoder, tty, worker_threads).
  • Catches the dispatch-by-string class of supply-chain evasion: process["bind"+"ing"]("dns"), fs[atob("...")](), child_process[methodName]().
  • Zero runtime cost — purely a compile-time refusal. Lives in the platform-agnostic HIR pass, so it applies uniformly to every backend: native LLVM, WASM, ArkTS / HarmonyOS, JS, Glance, SwiftUI.

What's untouched

  • Literal-string keys (fs["readFileSync"]("/tmp/x")) fold to PropertyGet and always pass.
  • Dynamic dispatch on user objects is unaffected — that's the single most important non-regression.
  • The check only fires when the receiver resolves to a known stdlib namespace (bare ident, default import alias, or import * as ns namespace import).

Opt-outs (listed in the error message itself)

  1. Static call (preferred): process.exit(0).
  2. Site annotation: // @perry-allow-dynamic on or above the call site. Stacks with // @ts-ignore and similar.
  3. Per-package allow list: perry.allowDynamicStdlibDispatch: ["@scope/dep"] in the host package.json, matched against node_modules/<pkg>/….
  4. Global disable: perry.allowDynamicStdlibDispatch: true in the host package.json.
  5. Env var: PERRY_ALLOW_DYNAMIC_STDLIB=1 for one-off builds; =0 enforces the check even when package.json opts out, so CI can gate.

Implementation

Plumbing follows the existing #665 thread-local pattern in collect_modules.rs:

  • Per-thread config (set_refuse_dynamic_stdlib_dispatch, set_allow_dynamic_stdlib_packages) installed before each lower_module_full call.
  • Per-module source-text stash (set_current_module_source) lets the annotation lookup resolve // @perry-allow-dynamic without re-reading the file from disk.
  • All thread-locals cleared after the lower call so rayon worker state can't leak across modules.

The check sits in lower_member.rs::lower_member's MemberProp::Computed arm, before index lowering, and unwraps TS type wrappers (Paren, TsAs, TsTypeAssertion, TsNonNull, TsConstAssertion, TsSatisfies) so idiomatic (process as any)[k]() still surfaces process as the receiver.

The error message names the namespace, cites issue #503, and walks the user through every opt-out path.

Files

  • crates/perry-hir/src/ir.rs — thread-locals + package_name_for_source_path helper + annotation-lookup helper.
  • crates/perry-hir/src/lower/expr_member.rs — the refusal check + stdlib_namespace_receiver helper (handles bare ident, builtin_module_aliases, namespace imports, TS-wrapper unwrapping).
  • crates/perry/src/commands/compile.rs — parses perry.allowDynamicStdlibDispatch from package.json (boolean or array), env-var override, applies config to the driver thread.
  • crates/perry/src/commands/compile/collect_modules.rs — re-installs config on each rayon worker before lowering.
  • crates/perry/src/commands/check.rs — installs the source-text thread-local during perry check so site annotations resolve there too.
  • crates/perry-hir/tests/dynamic_stdlib_dispatch.rs — 11 unit tests covering refusal, every opt-out path, namespace-extraction edge cases, and the diagnostic text.
  • docs/src/cli/dynamic-dispatch.md + docs/src/SUMMARY.md — user-facing documentation.

Test plan

  • cargo test -p perry-hir — 118 tests pass (incl. 11 new).
  • cargo test --release -p perry --tests — 244 tests pass.
  • cargo build --release -p perry-runtime -p perry-stdlib -p perry -p perry-codegen-wasm -p perry-codegen-arkts -p perry-codegen-swiftui -p perry-codegen-glance -p perry-codegen-js — all platform backends build clean.
  • End-to-end smoke test: (process as any)[k](0) fails with the new diagnostic; literal-key, // @perry-allow-dynamic, and PERRY_ALLOW_DYNAMIC_STDLIB=1 all compile cleanly.
  • No new warnings introduced.

Notes

  • No Cargo.toml version bump, no CLAUDE.md version line touch, no CHANGELOG.md entry — per the external-contributor PR policy / to avoid version-bump collisions while this is in review. Maintainer will fold those in at merge time.

… namespaces

Adds a compile-time HIR pass that rejects `obj[runtimeVar]()` and
`obj[atob(...)]()` shapes when `obj` resolves to a Node-core stdlib
namespace (process, fs, crypto, child_process, net, os, path, http,
https, http2, stream, url, util, events, dns, tls, querystring, zlib,
async_hooks, readline, string_decoder, tty, worker_threads). Catches the
dispatch-by-string class of supply-chain evasion used by malicious npm
packages — `process["bind"+"ing"]("dns")`, `fs[atob("...")]()`, etc.

Zero runtime cost — purely a compile-time refusal. Lives in the
platform-agnostic HIR pass so it applies uniformly to every backend
(native LLVM, WASM, ArkTS/HarmonyOS, JS, Glance, SwiftUI).

Opt-outs (priority order, listed in the error message itself):
- Static call: `process.exit(0)` — preferred fix.
- `// @perry-allow-dynamic` line annotation on or above the call.
- `perry.allowDynamicStdlibDispatch: ["@scope/dep"]` per-package allow
  list in the host package.json (matches `node_modules/<pkg>/...`).
- `perry.allowDynamicStdlibDispatch: true` global disable.
- `PERRY_ALLOW_DYNAMIC_STDLIB=1` env var for one-off builds; `=0`
  enforces the check even when package.json opts out (CI gate).

User-code reflection on user-defined objects is untouched — the check
only fires when the receiver resolves to a known stdlib namespace
(including aliased shapes like `import fs from 'fs'`, `import * as fs
from 'fs'`, and `(process as any)[k]`).

Plumbing follows the existing #665 thread-local pattern in
`collect_modules.rs`: per-thread config + per-module source-text stash
for the annotation lookup, both cleared after each `lower_module_full`
call so rayon worker state can't leak across modules.

Test coverage in `crates/perry-hir/tests/dynamic_stdlib_dispatch.rs`:
- bare ident, imported alias, namespace import all refused
- literal-string key, user-object dispatch unaffected
- site annotation works above and on the call line (and through
  stacked comment lines like `// @ts-ignore`)
- per-package allow list and global disable both opt out cleanly
- diagnostic names the namespace and every opt-out mechanism

End-to-end smoke-tested by compiling the three opt-out shapes against
the release binary.
@proggeramlug proggeramlug merged commit 77ab822 into main May 18, 2026
9 checks passed
@proggeramlug proggeramlug deleted the feat/503-refuse-dynamic-stdlib-dispatch branch May 18, 2026 07:03
proggeramlug added a commit that referenced this pull request May 19, 2026
…#1051)

#503/#941 honored the `// @perry-allow-dynamic` site annotation
regardless of source path. Since the threat model is malicious npm
packages, a hostile dep could defeat the check by writing the
annotation next to its own call. Closed by gating the site annotation
on host-code-only paths: when `package_name_for_source_path` returns
`Some(_)` the annotation is ignored.

Dependencies that legitimately need dynamic dispatch must opt in via
the host's `perry.allowDynamicStdlibDispatch` allow-list or the global
flag — the per-package path is host-controlled by design.

Also folded in:
- new `site_annotation_ignored_inside_node_modules` regression test
  with host-code counter-assertion
- doc drift fix in `crates/perry-hir/src/ir.rs` REFUSE_DYNAMIC_STDLIB_DISPATCH
  thread-local docstring: added `http2`, `async_hooks`, `readline`,
  `string_decoder`, `tty`, `worker_threads`; removed `buffer` (Buffer
  is a constructor, intentionally excluded), with a pointer to the
  canonical list in `lower/expr_member.rs::STDLIB_NAMESPACE_NAMES`
- `docs/src/cli/dynamic-dispatch.md` annotation section now spells
  out that the opt-out is host-code only
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.

security: refuse dynamic stdlib dispatch (obj[runtimeVar]())

1 participant