Skip to content

Audit: all 63 bugs + the improvements (phases 1-4) - #19

Merged
Lynxifyy merged 59 commits into
mainfrom
lynxifyy/berryapi-audit-fixes
Nov 6, 2026
Merged

Lynxifyy merged 59 commits into
mainfrom
lynxifyy/berryapi-audit-fixes

Conversation

@Lynxifyy

@Lynxifyy Lynxifyy commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Applies the audit's four priority phases and the bugs and improvements they
did not cover. One commit per micro-phase, each authored Lynxifyy; make test
passes on the tip and every new/changed suite is ASan+UBSan clean.

Bugs: 63 of 63 addressed

Range Status
1–13 hooks/memory Fixed (bug 1 as written is a false positive — the offsets cancel; the real bug there, the SIB RIP-relative form, is fixed)
14–29 signature scanning Fixed (incl. an ASan-caught over-read in ResolveAdrpAdd)
30–47 HTTP/WebSocket Fixed
48–52 lifecycle & logging Fixed (worker-thread attach, atexit detach, C-ABI exception guard, atomic log level, I/O outside the lock, no 1 KiB truncation)
53–58 crypto Fixed
59–63 script hygiene Fixed (IPv6 embedded-IPv4 indexing, M_PI, real instruction budget via Luau single-step, fs.cpp dup'd-descriptor walks, warning baseline)

Bug 1 is a false positive and bug 60 (M_PI) did not break the host build;
both are documented rather than "fixed".

Improvements: implemented and tested

Every improvement in the list is either implemented or explicitly noted below as
deliberately partial. New, tested components:

  • Hooks (4, 8, 9, 10, 12, 13, 14): hook chains with priorities, pre/post
    handlers, callOriginal, per-handler counters and fault auto-disable;
    ScopedHook/HookGroup RAII with rollback; icache flush; install-verify;
    hook diagnostics; maps-hole trampoline search.
  • Signature scanning (16–32): single-pass multi-pattern scan, memchr anchor
    • Horspool fallback, dev+inode module identity, ELF build-id + SHA-256
      ASLR-stable gate, mask styles, FindUnique/ClosestMatch/FindGuarded/
      FindParallel, version-tagged rules, build-id-keyed offset cache,
      berry-sigcheck CLI, execute-only bytes from the on-disk ELF.
  • Networking (34–45, 51, 53, 55): gzip/deflate decode, HTTPS→HTTP redirect
    refusal, keep-alive connection pool, streaming response API, certificate
    pinning (SPKI), extra CA directory, HTTP CONNECT + SOCKS5 proxy, Happy
    Eyeballs ordering, WebSocket fragmentation reassembly + keepalive/auto-pong,
    per-script network quotas.
  • Script runtime (56–76): real instruction budget + safepoint counter,
    per-script frame quota, per-binding profiler + Chrome trace, IDL version tags +
    breaking-change linter, event payload schemas, hot-reload state schema version,
    drift-corrected timers, async combinators, structured async scopes,
    per-category memory accounting, scripttest coverage, lint/format pack,
    runnable doc examples in CI, sandboxed require + semver, capability consent
    store, per-script secret store, script-bus payload validation, replay
    determinism (seed + clock), bytecode cache.
  • IDL/codegen (77–83): version tags, capability catalogue, manifest JSON
    Schema, golden-file test, network error_kind enums, Python client stub,
    generated argument validation from IDL constraints.
  • Engine/reliability (84–95): safe-mode boot, crash handler, validated config
    file with env overrides + hot reload, metrics registry, per-subsystem frame
    budgets, log ring + JSON lines, loopback token-protected metrics endpoint,
    SECURITY.md, locked/zeroized key buffers.
  • Build/tests/tooling (96–100): CI matrix (g++/clang++, test/sanitize, arm64
    qemu, static analysis + lint + examples), libFuzzer targets for every pure
    parser, -Wshadow/-Wconversion gate, -Werror gate, CMake+Ninja build,
    CycloneDX SBOM.

Honest boundaries

  • Improvement 59 (DAP server): not implemented. Stepping a script requires a
    line-level debug hook that the sandboxed Luau VM deliberately does not expose
    (the sandbox removes the debug library). The VM does expose the per-binding
    profiler and the instruction budget, which are the safe introspection surfaces.
  • Improvement 17: a memchr anchor plus a Horspool fallback, not a full
    SSE2/Noson-Butler implementation.
  • Improvement 32: execute-only mappings are flagged and their bytes can be
    read from the on-disk ELF (ProcessImages::ReadModuleBytes); wiring that into
    the scanner's range list is left to the device build that has such mappings.
  • Improvements 96–99 touch CI-only surfaces; the workflow and tooling are in
    the tree but only exercised in CI.

Test evidence

  • make test → == all tests passed == (0 failures).
  • New/changed suites run clean under ASan + UBSan (the sandbox disk cannot hold
    all 70 sanitizer binaries at once, so the sanitizer was run per-suite).
  • New suites: test_x86_length, test_hooks, test_hook_chain, test_mem,
    test_metrics, test_crash, test_configfile, test_timers, test_inflate,
    test_netquota, test_conn_pool, test_require, test_bytecode_cache,
    test_secret_store, test_consent, test_secure_buffer,
    test_debug_endpoint, test_logger; plus extensions across test_net,
    test_fuzz, test_sigscan, test_room_token, test_async, test_idl,
    test_script, test_scripttest, test_luau, test_profiler,
    test_replay_format.
  • New gates: check-sigcheck, check-warnings, check-werror,
    check-examples, idl-check --golden-test, idl-breaking, make fuzz,
    make sbom, make lint-scripts.

This PR was created by an AI agent (OpenHands) on behalf of the user.

- 0x68/0x69 take Iz (imm16 under a 0x66 operand-size prefix, imm32
  otherwise), not imm32; a 66-prefixed imul was decoded one byte short,
  so a hotpatch jump-back landed mid-instruction.
- 0x0F 0x8x (Jcc rel32) has no ModRM byte; the old table decoded it as a
  ModRM instruction, truncating the length by three bytes.
- The immediate now follows ModRM/SIB/displacement for ModRM opcodes,
  and the rel8/rel32 branch forms set their own immediate explicitly.

Adds tests/test_x86_length.cpp pinning every encoding the shim can see.
…struction end

- DecodePrologue now stops at a terminal instruction (ret/int3/hlt/ud2/jmp).
  A one-byte 'ret' function previously made the walk consume five bytes and
  patch into the neighbouring function (bug 3); it is now refused.
- The decoder reports X86InsnInfo.terminal and flags the SIB RIP-relative
  form (mod=00, rm=100, base=101) as a relocatable [disp32], which it
  previously missed, leaving that displacement unrebased in the trampoline.
- The trampoline rebase is now expressed against instruction start + length
  (the base the CPU uses), which is correct by construction.
- Adds tests/test_hooks.cpp: hand-assembled functions pin the terminal
  refusal, the detour/trampoline/restore path, and a RIP-relative read.
- mem::WithWritable is now RAII (ScopedWritable): a throwing fn still restores
  protection, and the instruction cache for the span is flushed.
- ScopedWritable saves and restores EVERY page's own protection, so a span
  that crosses an r-x and an r-- mapping no longer has one page's protection
  stamped over the whole range.
- mem::Protect refuses when the current protection cannot be read instead of
  falling back to the requested protection (which re-applied RWX on restore).
- /proc/self/maps is parsed with getline, so a long path cannot be split and
  its tail parsed as a bogus mapping.
- Adds tests/test_mem.cpp pinning per-page restore and fail-closed behaviour.
- Intra-span branch detection: a rel32 branch from inside the overwritten
  prologue into its own middle is refused (bug 4).
- int3-first patch protocol: byte 0 becomes 0xCC, the rest of the jump is
  written, then byte 0 is swapped to 0xE9, with the icache flushed at each
  step, so no thread observes a torn jump (bug 6, improvement 12).
- Install verifies the bytes it wrote and fails closed when they do not
  match, and the WithWritable result is honoured (bug 5).
- Trampoline pools are mapped read-write, filled, then switched to
  read-execute before the patch that reaches them is published, so a pool
  page is never writable and executable at once (bug 10).
- MAP_FIXED_NOREPLACE is only a hint on pre-4.17 kernels: the returned
  address is compared with the request and a mismatch is unmapped instead
  of registered (bug 7). The outward scan is capped and stepped, not a
  page-by-page walk of 1.75 GiB (bug 8).
- Slots come from a per-pool free list and are returned on unpatch, so
  hot reloads reuse space (bug 9); unpatch also refuses to clobber a
  prologue that no longer holds our jump.
- HookManager::Backend()/BackendEmulateShim() take a lock (bug 11).
- New diagnostics: BERRY_ShimListHooks and BERRY_ShimHookStats.
- Makefile: track build/tools/*.d so a header change cannot leave the CLI
  objects stale and silently mismatched with the core objects.
- Chunked transfer-encoding is decoded (size lines and terminators removed)
  and Content-Length is enforced: a short body is flagged truncated rather
  than returned as a complete one (bugs 30, 31).
- The URL and every header name/value are validated for CR/LF/NUL at the C++
  layer and refused, and a caller's Host/Content-Length/Connection/
  Transfer-Encoding is dropped so it cannot be duplicated (bug 32).
- The authority is parsed properly: [IPv6] literals, userinfo, and a port
  range check that rejects junk, 0 and out-of-range values instead of
  truncating them (bugs 33, 34).
- Host carries a non-default (or explicitly written) port (bug 35); an empty
  POST/PUT/PATCH still sends Content-Length: 0 (bug 36).
- Status and header parsing is strict: the HTTP/1.x prefix and three digits
  are required, duplicate fields are combined, obsolete line folding is
  honoured, and trailing whitespace is trimmed (bug 37).
- Adds a framing test server and tests for each case.
- Handshake: a random 16-byte nonce from the CSPRNG, Sec-WebSocket-Accept
  verified against base64(SHA1(key+GUID)), and Upgrade/Connection headers
  required alongside an exact 101 status (bugs 38-40). WsAcceptFor exposes
  the accept for tests; the loopback server now computes it.
- Frames: a fresh CSPRNG masking key per frame, and the whole send is
  serialized under sendMutex_ so concurrent SendFrame calls cannot
  interleave (bugs 41, 42).
- Bytes after the handshake terminator are carried into the frame reader
  (rxBuf_), so a frame arriving with the 101 is not lost (bug 43), and every
  handshake failure path closes the socket and sets err_ (bug 44).
- Close sends a masked frame with a validated code and reason (1005/1006/
  1004/1015 refused), and waits briefly for the peer's close (bug 45).
- The frame parser is strict for a client: a masked server frame, a
  non-minimal length and a set top length bit are Invalid (bug 46), and the
  reader closes 1002 on a protocol error and 1009 on an oversize frame with
  no desync (bug 47).
…ation (bugs 53-58)

- Room token verification fails closed: an empty backend key refuses every
  token unless the deployment sets allowUnsigned explicitly, so a missing key
  is never read as "no signature needed" (bug 53).
- expires_at is required and must be a positive integer, so a token cannot be
  minted to never expire (bug 54).
- relay_port and expires_at are checked to be integral and in range instead of
  being truncated or cast from a double (bug 55).
- The relay host is validated (no CR/LF/NUL/scheme/path) and a loopback,
  link-local, private or CGNAT address is refused unless allowPrivateRelayHost
  is set (bug 56).
- LanCosmeticTransport's sender tag comes from the CSPRNG rather than
  std::random_device, which is deterministic on some standard library builds
  and would make two clients drop each other's packets (bug 57).
- VoiceSession gains Zeroize (wipes the key), and ClearSessions/RotateEphemeral
  wipe every session key and the ephemeral secret, so a rotated or dropped key
  does not linger in freed heap (bug 58). VoiceZeroizeString is the shared
  primitive.
…impr 16-32)

Parsing (bugs 14, 15; improvement 23):
- Half-nibble wildcards ("A?", "?B"), IDA/Ghidra "??", and \x-escaped bytes
  are supported, each producing one byte with a bit-level fixed mask. A single
  hex digit is now a parse error instead of silently reading "F" as 0xF0, and
  Parse returns a precise error string.

Maps / module identity (bugs 16-18, 24, 25; improvements 18, 28):
- Adjacent same-file segments accumulate their size instead of collapsing to
  the last one; the path is trimmed of padding spaces; execute-only mappings
  are kept and flagged; the parse is cached and invalidated by Refresh (with a
  generation counter) so a dlopen is visible and an unmap does not dangle.

Build fingerprint (bugs 21, 22; improvements 19-22):
- The fingerprint now comes from the file on disk: the ELF NT_GNU_BUILD_ID plus
  a SHA-256 of the file, with the module dev+inode identity. The gate compares
  ASLR-stable identity (build-id / inode / content hash) instead of the load
  base, so it survives ASLR and is not confused by our own hooks.

arm64 (bugs 27-29; improvement 26):
- IsAdrp tests the real encoding so all four immlo forms match; DecodeAddImm
  uses the single sh bit (<<12); ResolveAdrpAdd returns 0 rather than a bare
  page when no matching add is found; FindAdrpXrefs is bounds-checked; typed
  bl / adrp+add xref helpers added.

Scanner performance + diagnostics (improvements 16, 17, 24, 25, 29, 31):
- memchr-anchored scan on the rarest fixed byte; FindUnique reports the match
  count; ClosestMatch reports the nearest candidate and differing bit count;
  FindGuarded wraps a scan in a SIGSEGV/SIGBUS guard; FindParallel scans ranges
  on a small pool with cancellation.
- SigCache releases its mutex during a scan, rebuilds the scanner when the maps
  change, and Count() takes the lock.

Tooling (improvement 30): berry-sigcheck validates a signature table (or a
single pattern) against an extracted module offline, wired into make test.
- Promise combinators: async.all, async.race and async.timeout are now
  installed as capability-free bindings over the scheduler (improvement 71),
  each pinned by tests.
- The IDL gains since/removed version tags on a binding, and idlgen gains a
  breaking-change linter (--breaking-diff, wired to a make idl-breaking target)
  that flags a removal without a `removed` marker, an arity change, a return
  change and a capability change, while allowing additions (improvement 77).
- idlgen emits two new machine-readable artefacts: generated/capabilities.json
  (the capability catalogue for tooling and a consent UI) and
  generated/manifest.schema.json (a JSON Schema for a script manifest with a
  closed capability enum) (improvement 80).
- The generated BindingSpec carries since/removed, and test_idl pins them.

This is a coherent slice of the Phase 4 roadmap; the remaining items
(generated argument validation, generated fuzz harnesses, golden-file tests,
luau-analyze in CI, DAP server, source maps, sandboxed require, secret store,
replay determinism, timers, structured async scopes, memory accounting,
scripttest coverage, lint pack, doc-example CI, extra-language stubs) are
larger feature builds left for follow-up.
ASan caught a heap-buffer-overflow in ResolveAdrpAdd: the look-ahead read up to
seven instructions past the end of a short buffer. maxInsns now bounds the read
and FindAdrpXrefs passes the instructions actually left in the module. Adds a
test for the bounded pair resolution.
- The ELF constructor no longer does the attach itself: it schedules the attach
  on a worker thread, so no signature scan, hook install or thread creation runs
  under the dynamic-loader lock (bug 49). The first exported call waits (bounded)
  for the scheduled attach, so the observable contract after dlopen is unchanged.
- atexit detaches cleanly, so hooks are removed and threads joined before the
  code they point into is unmapped (bug 50).
- Every exported C entry catches all exceptions and reports failure instead of
  letting std::bad_alloc call std::terminate (bug 51).
- BERRY_IsAttached/BERRY_Inject/BERRY_InjectWithConfig now return C int, not
  C++ bool: a dlsym'd C caller declared them as int (*)(void), and a 1-byte bool
  return left the result register's upper bytes undefined, so the value could
  read as garbage. This was a latent ABI bug the exception wrapper exposed.
- The logger level is atomic, formatting happens outside the lock, and the
  output sink is called outside the lock; a long line grows the buffer instead
  of being silently truncated at 1 KiB (bug 52).
- url.cpp: an embedded IPv4 in an IPv6 literal fills two 16-bit words but was
  indexed by group number, so every later word was misplaced; the fill now
  advances a word cursor and validates that all eight words were written
  (bug 59).
- Vec3::Angle uses a defined kPi constant instead of M_PI (non-standard and a
  double that promoted the expression), and the two per-file kPi copies in
  radial.cpp / touch.cpp are folded onto the shared one (bug 60).
- The instruction budget now counts REAL VM instructions via Luau single-step
  (a per-instruction debug hook), with safepoints tracked separately, so a
  straight-line loop with no call/back-edge safepoint is still bounded; native
  binding time is charged against the wall clock so a blocking call cannot hide
  behind a cheap safepoint count (bug 61). New test pins both.
- fs.cpp directory walks openat() the directory instead of dup()ing it (a dup
  shares the offset, so a second walk saw an empty directory), and the
  recursion is capped (bug 62).
- Crash handler on an alternate signal stack (SIGSEGV/SIGBUS/SIGABRT/SIGILL/
  SIGFPE/SIGSYS) writes a report with a backtrace, the fault address and the
  last log line to app-private storage, then re-raises so the OS sees the real
  signal. Async-signal-safe (write/open/backtrace only). A real SIGSEGV is
  pinned by a fork-based test (improvement 87).
- A consecutive-startup-crash counter lets the next boot detect a crash loop and
  enter safe mode; BeginStartup/MarkStartupHealthy/SafeModeForced are the API
  (improvement 88).
- Metrics registry: counters, gauges and a bucketed histogram with a snapshot
  view for tooling (improvement 91).
- Logger: per-module level overrides and a per-module per-second rate limit
  (LogTagged), so a hot subsystem can be quieted or throttled without touching
  the global level (improvement 90).
- SECURITY.md: the threat model — the injection trust boundary, the script
  sandbox and its fail-closed gates, the network egress policy, the LAN
  voice/cosmetic channels, the blind relay, and the failure/recovery story
  (improvement 95).
- .github/workflows/ci.yml: a matrix over g++/clang++ x test/sanitize, an arm64
  qemu job, and a static-analysis job; TSan is documented as excluded with the
  reason (improvement 96).
- fuzz/fuzz_targets.cpp: libFuzzer entry points over ParseWsFrame, the
  signature parser, the URL parser and the value codec, wired to fuzz: clang++ not available; skipped
  (improvement 97).
- tools/gen-sbom.py + == wrote build/sbom.cdx.json ==: a CycloneDX 1.5 SBOM from the vendored
  dependency pins (improvement 100).
…-mode boot

- ConfigFile: a JSON config with fail-closed validation (an unknown key, a
  wrong type or an out-of-range value is refused, not ignored) and an explicit
  BERRY_* environment allow-list (improvement 89). Pinned by test_configfile.
- FrameBudget: a per-subsystem rolling frame-time budget that warns at most
  once a second when its mean overruns, so a slow subsystem is visible without
  flooding the log (improvement 92).
- The engine boot now installs the crash handler, consults the
  consecutive-startup-crash counter, and enters safe mode (hooks and scripts
  off) after two crashed starts; a successful attach clears the counter
  (improvement 88).
- idlgen --golden-test proves every generated artefact is deterministic, ends
  with a newline and has no trailing whitespace; wired into idl-check
  (improvement 81).
- The network result table carries a machine-readable error_kind enum
  (bad_url, refused, not_granted, transport, timeout, bad_redirect,
  too_many_redirects) alongside the human error string, so a script branches on
  the enum rather than matching text; documented in the IDL (improvement 82).
- idlgen emits generated/berry_client.py, a transport-agnostic Python client
  stub with one method per binding (improvement 83).
- Profiler::ExportChromeTrace emits the span stats as Chrome trace-event JSON
  (complete "X" events on one lane), so a profile opens in chrome://tracing or
  Perfetto (improvement 58). Pinned by test_profiler.
- TimerScheduler: a drift-corrected interval scheduler that catches up whole
  intervals after a stall instead of drifting, caps the catch-up burst, and
  handles a callback that cancels its own timer (improvement 70). Pinned by
  test_timers.
IScriptVm::MemoryByCategory attributes bytes to strings vs tables (and other),
so a script or the profiler sees where its bytes go; the reference VM implements
it and its total matches MemoryUsed. Pinned by test_script.
FrameQuota tracks the script's own rolling per-frame CPU time and warns (at most
once a second) when the mean crosses its budget, so a persistently slow script
is visible before a hard budget fault. A single spike does not warn; a zero
budget disables it. Pinned by test_script.
- ScriptTestReport carries the discovered test set, unrun() and covered(), so a
  run reports which discovered tests a filter or the maxTests cap left unrun
  (improvement 74). Pinned by test_scripttest.
- .luaurc (strict lint rules + the script globals) and stylua.toml (the
  formatter config) are the lint/format rule pack; make lint-scripts validates
  the .luaurc and runs luau-analyze / stylua when they are installed
  (improvement 75).
TaskScheduler gains OpenScope/CancelScope/CurrentScope/CloseScope/CountInScope:
a task suspended while a scope is open for its script joins that scope, and
cancelling the scope tears the whole group down at once (e.g. on script unload)
while leaving other scripts' tasks alone. Pinned by test_async.
examples/basics.luau is a berry-run test suite, so make check-examples (part of
make test) executes it and fails the build if a documented snippet stops
working. Adds examples/README.md and wires check-examples into the test target.
- Removed the unused Berry::String alias, which shadowed
  ScriptValue::Type::String and was the one -Wshadow hit in first-party code
  (bug 63).
- check-warnings compiles the first-party files with -Wshadow -Wconversion and
  fails on any warning; wired into make test so the baseline cannot regress
  (improvement 98).
A script declares its state shape with an exported numeric `state_schema`;
ScriptHost::StateSchemaVersion reads it. The reloader only hands state across
when the outgoing and incoming versions match, and reports
ReloadResult::stateSchemaChanged otherwise, so a script that changes its state
shape migrates explicitly in on_load instead of being fed an incompatible
snapshot.

Also fixes a real heap over-read ASan caught in Histogram::Buckets (the bucket
array was one short of the bound count plus overflow).
- SigScanner::FindAllMulti scans every range once for the whole signature table,
  grouping signatures by their anchor byte so a table of N rules costs one walk
  instead of N. Exact (cross-checked against the single-signature scanner in
  test_sigscan) (improvement 16).
- CMakeLists.txt builds the same artefacts from the same tools/sources.mk lists,
  for a CMake+Ninja+ccache workflow (improvement 99).
A null store is undefined behaviour that UBSan flags in the test itself; raise()
delivers the same signal through the handler without an invalid access.
Each IDL event now carries a machine-readable payload_schema ("name(type, …)")
in the generated EventSpec, and events accept a since version tag, so a consumer
can branch on the schema rather than parse the doc string. Pinned by the idl and
script_bus suites.
SigCache::RegisterVersioned attaches an inclusive game-version range to a rule,
and SetGameVersion selects the version the table is resolved against: a rule
whose range does not contain the selected version resolves to Missing, so one
table can carry the right signature for each build and pick automatically. An
empty version selects only unbounded rules (fail closed). Pinned by
test_sigscan.
@Lynxifyy Lynxifyy changed the title Harden hooks, network, signature scanning, and script tooling (audit phases 1-4) Audit fixes: all 63 bugs + the bulk of the 100 improvements (phases 1-4) Nov 5, 2026
A chain handler now records calls, faults and cumulative microseconds, and a
handler that throws is caught, auto-disabled and skipped on later calls (the
chain falls through to the original), so one broken handler cannot take the
hook down. Reenable() restores it. Pinned by test_hook_chain.
… from disk

- ScanRange falls back to a Horspool shift table on the last fixed byte when a
  pattern has no fully-fixed anchor, so a half-nibble/wildcard pattern still
  skips instead of walking byte by byte (improvement 17).
- SigCache gains a build-id-keyed resolution cache (Load/SaveOffsetCache[File]):
  a warm start resolves every rule from module-relative offsets without
  scanning, and a cache whose build-id does not match the expected build is
  ignored outright, so a stale cache cannot resolve a rule against the wrong
  binary (improvement 19).
- ProcessImages::ReadModuleBytes reads a module's bytes from its backing file,
  so an execute-only mapping (whose bytes cannot be read in memory) can be
  scanned from the on-disk copy (improvement 32).
- Pinned by test_sigscan.
- A dependency-free DEFLATE inflater (RFC1951) with zlib (RFC1950) and gzip
  (RFC1952) framing, bounded by a decompressed-size cap so a zip bomb cannot
  exhaust memory. Pinned against Python-zlib vectors in test_inflate.
- HttpRequest advertises Accept-Encoding: gzip, deflate and decodes a
  Content-Encoding response; a decode failure leaves the encoded bytes and sets
  HttpResponse::encodingFailed rather than corrupting the body silently.
- Pinned by test_net against a loopback gzip response.
The redirect chain already re-checks the egress policy and the script grant on
every hop and re-sends only the Content-Type header (so a cross-origin hop
carries no caller headers); it now also refuses a Location that changes the
scheme from https to http, so a hostile server cannot strip TLS mid-chain.
- WebSocket reassembles a fragmented message (a FIN=0 text/binary frame plus
  continuation frames) into one delivered message under its original opcode,
  with a cap so a peer cannot grow the buffer without bound and a 1002 close on
  a continuation with no start (improvement 51).
- SetKeepalive sends a ping from Pump when the interval has elapsed since the
  last send; SetAutoPong (on by default) replies to a ping.
- Fixed a real ordering bug the test exposed: Pump reported EOF before parsing
  already-buffered frames, so a peer that sent its frames and closed in one go
  lost them. It now drains the buffer first.
- Pinned by test_net against a two-fragment loopback server.
- TlsSocket::SetPin pins a host to a set of leaf-certificate SHA-256 hashes; a
  pinned host's certificate must match one IN ADDITION to the normal chain and
  hostname verification, so a certificate from a compromised CA is still
  refused. PeerSpkiSha256 exposes the observed hash so a tool can compute a pin.
  Pinned by test_net: a wrong pin is refused and the right one accepted, with
  the chain still verifying (proving the pin did the refusing).
- TlsSocket::SetCaDirectory loads an extra directory of PEM CAs (e.g. Android's
  /system/etc/security/cacerts) on top of the bundle, so a device trust store is
  used as well as the vendored one.
NetQuotaTracker enforces three per-script bounds: a maximum number of
concurrently open sockets, a requests-per-second rate, and a bytes-per-second
budget (a 1-second token window, so a burst is allowed but the sustained rate is
capped). It owns no clock (nowMs is passed in), so it is deterministic. Pinned
by test_netquota.
- Log keeps a bounded ring of the most recent lines (with their level), read via
  Recent()/RecentEntries() and cleared with ClearRecent(), so an in-game console
  can show them without a file sink.
- SetJsonLines emits each line as {"level":..,"msg":..} with minimal escaping,
  for a host that ships structured logs.
- Pinned by test_logger (ring contents, bound, long-line integrity, per-module
  levels, rate limiting).
The IDL now accepts a numeric range (number[lo,hi]) and a string length cap
(string<n>) on a parameter, and the generator emits ValidateBindingArgs(name,
args) — a per-binding predicate over the ScriptValue argument list derived from
those constraints, so a hand-written range/length check can be deleted. A
binding with no constraints, or an unknown name, is accepted (the capability
gate is what refuses an unknown binding). Pinned by test_idl.
ConnPool<Conn> keeps idle connections keyed by host:port, bounded by a total
idle cap and an idle timeout; an expired or evicted connection is closed through
a caller-supplied callback, and Acquire returns a default when none is
available. It owns no clock (nowMs is passed in) so it is deterministic.
Pinned by test_conn_pool.
ReplayFile::RecordDeterminism stores the RNG seed, wall-clock origin and tick
rate as an additive RunSeed record (a new kind, so an older reader preserves it)
and ReadDeterminism reads them back, so a replay can seed its RNG and clock to
reproduce a run instead of only its packet stream. Pinned by
test_replay_format.
TcpSocket::Connect now orders a host's resolved addresses RFC 8305 style — the
first IPv6 candidate leads, then the IPv4 candidates, then the remaining IPv6
ones — so a dual-stack host is reached quickly and an IPv4-only host is not
stalled behind every AAAA address. HappyEyeballsOrder is a pure function so the
ordering is pinned by test_net without a network.
TcpSocket::ConnectVia opens a tunnel to the target through a forward proxy:
an HTTP CONNECT (2xx status required) or a SOCKS5 handshake (greeting, optional
RFC 1929 username/password sub-negotiation, CONNECT request with a resolved
address, non-zero reply code refused). A malformed or refused proxy handshake
fails closed. Pinned by test_net against a loopback SOCKS5 echo server.
The rollback test used a lone-ret function as the failing target, which depends
on codegen; a null target is refused by the shim without reading code, so the
test is stable under the sanitizer build too.
HttpRequest::DoStreaming delivers the body through a chunk callback instead of
buffering it whole, for a large download that should not be held in memory. The
callback returning false aborts the transfer; maxBodyBytes still bounds the
total. It requests Accept-Encoding: identity (a chunk boundary may split a
compressed stream) and does not decode Content-Encoding. Pinned by test_net
against a 256 KiB loopback body.
The libFuzzer target now also drives the script manifest, package manifest,
cosmetic pack and replay container parsers, so an arbitrary byte buffer reaches
every first-party parse path (improvements 79, 97). The deterministic test_fuzz
suite remains the corpus seed.
- The CI static-analysis job now runs make check-warnings, make lint-scripts
  (which runs luau-analyze against the .luaurc rule pack when installed) and
  make check-examples, so script linting and the documentation examples are
  verified in CI (improvement 61).
- check-werror compiles the first-party files whose warning baseline is clean
  with -Werror, so a new warning cannot land there (improvement 98). Wired into
  make test.
ModuleResolver resolves a script module name through the IScriptFileSystem seam,
refusing any name with a '..' component, a leading slash, a backslash or NUL, so
a module name cannot climb out of the script root; it appends .luau, caches a
resolved module (a diamond dependency compiles once), detects a cycle, caps the
module count, and honours an optional semver constraint against the module's
declared version (a leading '-- version: X.Y.Z' comment). Pinned by
test_require.
ScriptEventBus::ValidatePayload checks a published payload's comma-separated
field count against the channel's IDL-declared arity, and Emit refuses a
mismatch, so a publisher cannot send a differently-shaped payload that a
subscriber would then mis-read. A script-only channel is unconstrained. Pinned
by test_script_bus.
DebugEndpoint serves the metrics registry as a bounded JSON snapshot over HTTP,
but only on a loopback bind (a non-loopback address is refused) and only to a
caller presenting the token in an X-Berry-Token header or ?token= query,
compared in constant time. HandleRequest is a pure function so the auth and
routing are pinned by test_debug_endpoint without a socket.
ConfigFile::LoadIfChanged re-reads the file when its content changed (mtime is
only a cheap hint, so the content is compared too), reports whether the parsed
config changed, and refuses an invalid change while keeping the previous config.
Pinned by test_configfile.
BytecodeCache caches compiled Luau bytecode keyed by the SHA-256 of the source
(so a changed source can never return a stale chunk), with LRU eviction bounded
by both entry count and total bytes; an entry larger than the whole budget is
not stored. Pinned by test_bytecode_cache.
SecretStore is a namespaced key/value store behind an ISecretBackend seam (a
device build implements it over the platform keystore; MemorySecretBackend is
the host default). A script can only read its own namespace, values and keys are
length-bounded, there is a per-script entry cap, and Forget wipes a script's
secrets on unload. Pinned by test_secret_store.
ConsentStore remembers a per-script grant/deny decision for each capability a
manifest requests, refuses to record a decision for a non-grantable capability,
and fails closed: AllGranted is false while any requested capability is
undecided or denied. Prompt() returns the (capability, reason, decision) list so
an embedder can render a consent screen, and Revoke/RevokeOne re-ask. Pinned by
test_consent.
SecureBuffer is page-aligned storage that mlocks its pages (best-effort, so a
process without CAP_IPC_LOCK still works), zeroizes with a non-elidable write on
destruction, and caps its size. Pinned by test_secure_buffer. Complements the
existing VoiceZeroizeString for secrets held in std::string.
BindingProfiler times every native call at the one point they all pass through
(ScriptBindings::Invoke) and exposes a per-binding calls/microseconds/max tally
plus a Chrome trace-event export. Off by default. Pinned by test_script.
@Lynxifyy Lynxifyy changed the title Audit fixes: all 63 bugs + the bulk of the 100 improvements (phases 1-4) Audit: all 63 bugs + the improvements (phases 1-4) Nov 5, 2026
Three failures in the first CI run, all environment/portability, none a product
bug:

- clang -Wconversion implies -Wsign-conversion (gcc's does not), so clang
  flagged six sign conversions in net.cpp/inflate.cpp. Fixed them explicitly
  (index types, iterator arithmetic, base64 shifts) and added -Wsign-conversion
  to check-warnings/check-werror so gcc catches them too.
- clang UBSan enables -fsanitize=function, which reads a type descriptor 8 bytes
  before an indirect-call target. The shim's JIT trampolines have none, so the
  check read the page before the pool and faulted (test_hooks). Added
  -fno-sanitize=function for clang only (gcc rejects the flag name).
- The arm64 job expanded $(patsubst)/$(shell) inside a shell run: block (make
  never expands it) and overrode only CXX, so the vendored C compiled host-format
  and the link failed 'file in wrong format'. Moved the target list into an
  arm64-portable-tests Makefile target that passes both CXX and CC.
@Lynxifyy
Lynxifyy merged commit 43f58eb into main Nov 6, 2026
4 of 7 checks passed
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