Skip to content

fix: combined WASM hardening (stack guard, v18 errors, scan OOM, sync recovery, ESM, native parity) - #190

Open
pyramation wants to merge 21 commits into
mainfrom
devin/1792902893-combined-hardening
Open

pyramation wants to merge 21 commits into
mainfrom
devin/1792902893-combined-hardening

Conversation

@pyramation

Copy link
Copy Markdown
Collaborator

Summary

One PR that folds in every open hardening PR, plus pins for the combined fork branches: v18 → libpg_query #13 tip 3606788, v17 → #11 tip ab619b8.

Folded PR Broken → fixed
#180 Deeply nested SQL overflowed the C shadow stack or corrupted it for later calls → libpg_query stack guard (set_stack_guard() on every export) plus stackSave/stackRestore around each WASM call.
#182 @pgsql/parser ESM import failed on Node 18 (Unexpected token 'export') → ESM entry points ship as .mjs.
#183 v18 fingerprint/normalize returned lexer errors as the result, scan threw a JSON SyntaxError or returned empty tokens → structured SqlError via WasmDetailedResult, and out of WASM memory when there isn't enough memory.
#188 ~50 MB v18 scan aborted inside libpg_query and corrupted the instance → pinned fork with checked scan allocations; its "out of memory" is reported as out of WASM memory.
#187 Nothing checked WASM output against native libpg_query → scripts/native-parity.js in CI.
#189 After a failed call, sync calls in the same tick ran on the broken instance → synchronous replaceInstance(), plus a same-tick cap on repeated over-limit calls.
libpg_query #14 (via #13) parsePlPgSQLSync of a BEGIN ATOMIC SQL function aborted (proc_source != NULL assertion) → new test in versions/18/test/plpgsql.test.js.

callWasm keeps both behaviours: #180's stackRestore(stackPointer) runs first in the catch, then #189's replacement logic. One extra commit adds stackSave/stackRestore to the full-API WasmModule interface, because v18 build:js failed tsc without them.

Local results (combined pins, Docker emsdk build)

See the comment below for v17/v18 suites, test:stress and native parity.

Expected wasm/libpg-query.wasm sha256:

  • v17 (ab619b8): 97ceae55660c18b2b6e379c9a3a93f667f477947740a7532aaf0056075db06c9
  • v18 (3606788): bbad03f9c704f4b76838c0e15a62a0dd67ad37f7baea1577823eeb98ba3f2864

Merge order

  1. Merge constructive-io/libpg_query Fingerprint support #13 (18-constructive) and Bump tar from 6.1.0 to 6.1.5 #11 (17-constructive) with a merge commit, not squash. These pins are their head commits.
  2. Merge this PR, then re-run CI.

Until step 1, the v17/v18 Build WASM Object jobs fail with fatal: reference is not a tree and the dependent jobs are skipped. That is expected.

Link to Devin session: https://app.devin.ai/sessions/68876ce060664609ab6b63c8bed91e78
Open in Devin Desktop: https://app.devin.ai/desktop/session/68876ce060664609ab6b63c8bed91e78?variant=devin
Requested by: @pyramation

libpg_query's check_stack_depth() never had a base pointer, so recursion
past the WASM stack trapped (memory access out of bounds). Call the fork's
pg_query_set_stack_limit() at every export with the stack actually left
(weak symbol: upstream pins skip it) and pin 17/18 to the fork branches.
Restore the shadow stack pointer in callWasm after a throw so sync calls
on the stale instance do not start half-way down it. Regression test:
10 parseSync of a 100k-term expression under --stack-size=8000.
…/1792902893-combined-hardening

# Conflicts:
#	templates/full/wasm_wrapper.c
#	versions/18/Makefile
#	versions/18/package.json
#	versions/18/src/wasm_wrapper.c
…in/1792902893-combined-hardening

# Conflicts:
#	templates/full/index.ts
#	templates/index.ts
#	versions/13/src/index.ts
#	versions/14/src/index.ts
#	versions/15/src/index.ts
#	versions/16/src/index.ts
#	versions/17/src/index.ts
#	versions/18/src/index.ts
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 1 medium

💬 Inline comments (2)


This PR reworks the WASM wrappers to recover from out-of-memory and stack-overflow failures by replacing the Emscripten instance and unwinding the shadow stack, adds a detailed-result ABI (WasmDetailedResult with wasm_free_detailed_result), and hardens build_scan_json string escaping. It also introduces a native/WASM parity harness (scripts/native-parity.js + C driver) wired into CI, and updates ESM packaging (prepare.js, export maps, esm.test.mjs), with the same changes replicated across PG versions 13–18.

Files Change
templates/index.ts, templates/full/index.ts, versions/*/src/index.ts Loader and callWasm rework: instance replacement on OOM, largeUnfreed guard, stack save/restore on throw, detailed-result reading
templates/wasm_wrapper.c, templates/full/wasm_wrapper.c, versions/*/src/wasm_wrapper.c C-side additions: control-char escaping in scan JSON, wasm_scan/wasm_free_detailed_result exports, weak pg_query_set_stack_limit
templates/Makefile.template, versions/*/Makefile Exported runtime methods and functions updated to match the new wrappers
scripts/native-parity.js, scripts/native-parity/driver.c, .github/workflows/ci.yml New parity harness that compiles a native driver, diff JSONL outputs against WASM, run in CI
parser/package.json, parser/scripts/prepare.js, parser/templates/index.js.template, parser/test/esm.test.mjs, versions/17/package.json, versions/18/package.json Dual ESM/CJS packaging: .mjs rename in prepare step, exports maps, Node-flag-aware ESM test
versions/*/test/* New recovery/stale-stack tests plus scan, normalize, plpgsql, and fingerprint tests for the new behavior

Reviewed commit: 7abd626

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds WASM OOM/stack-recovery handling with an instance-replacement loader, control-character escaping and a detailed-result ABI in the C wrappers, a native-parity harness, and ESM packaging updates, copied across versions 13–18.

Key findings

Comment on lines 362 to +385
@@ -326,6 +381,8 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi
} else if (c == '\t') {
escaped_text[escaped_pos++] = '\\';
escaped_text[escaped_pos++] = 't';
} else if ((unsigned char) c < 0x20) {
escaped_pos += snprintf(escaped_text + escaped_pos, 7, "\\u%04x", (unsigned char) c);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 security · high

Size escaped_text to fit the \u00XX escape

In build_scan_json, the control-character escape can write past the end of escaped_text: the buffer is sized token_length * 6 + 1 (templates/full/wasm_wrapper.c:362), but after the final control character escaped_pos equals 6 * token_length and snprintf(..., 7, ...) writes 7 bytes (6 chars plus NUL), overflowing up to 6 bytes beyond the allocation (templates/full/wasm_wrapper.c:385). Attacker-supplied SQL containing control characters not explicitly handled (e.g. \f, \v) reaches this path through wasm_scan, so the overflow corrupts adjacent WASM heap and can yield corrupted scan JSON or a crash.

📋 Prompt for AI Agents

In templates/full/wasm_wrapper.c around lines 362-385, the escape buffer is allocated as safe_malloc((size_t) token_length * 6 + 1) but the \u%04x escape uses snprintf(escaped_text + escaped_pos, 7, ...) which can write 7 bytes starting at offset 6 * token_length, overflowing by up to 6 bytes. Change the allocation to safe_malloc((size_t) token_length * 6 + 8) so the NUL terminator of the last snprintf always fits. Apply the same fix to the identical code in versions/18/src/wasm_wrapper.c (same pattern around line 369).

Comment thread templates/full/index.ts
Comment on lines +231 to +233
if (wasmModule.HEAPU8.length > LARGE_INSTANCE_BYTES && largeUnfreed++ === 0) {
queueMicrotask(() => { largeUnfreed = 0; });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Reset largeUnfreed only after memory can be reclaimed

The repeated-OOM guard resets largeUnfreed to 0 in a queueMicrotask (templates/full/index.ts:232), which fires at the first yield even though the replaced >512 MiB instance is only reclaimed by a later GC. In the common try { await parse(huge) } catch {} loop, every iteration yields, so largeUnfreed returns to 0 and each OOM failure replaces the instance again while previously dropped 1 GiB instances may still be resident — the opposite of the cap the comment at templates/full/index.ts:154-156 promises. Users looping over oversized inputs with awaits between attempts can accumulate multiple GiB of un-reclaimed WASM memory.

📋 Prompt for AI Agents

In templates/full/index.ts (mirrored in templates/index.ts and versions/13..18/src/index.ts), callWasm's catch block around lines 231-233 schedules queueMicrotask(() => { largeUnfreed = 0; }) when a >512 MiB instance is replaced. Replace the microtask with setTimeout(() => { largeUnfreed = 0; }, 0) (or decrement by one per yielded large failure) so the one-large-instance-per-run budget does not reset across microtask-only yields while V8 has not yet reclaimed the dropped instance's WebAssembly.Memory. Keep the largeUnfreed++ === 0 scheduling condition unchanged so only one reset is ever pending per run, and update test/wasm-recovery.test.js expectations accordingly.

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