Skip to content

fix(catalog): bind asset reads to checked project files - #3735

Merged
jrusso1020 merged 3 commits into
mainfrom
fix/security-catalog-asset-files
Sep 6, 2026
Merged

fix(catalog): bind asset reads to checked project files#3735
jrusso1020 merged 3 commits into
mainfrom
fix/security-catalog-asset-files

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Catalog asset processing could check a source pathname and then read a different file after replacement. A shared reader now resolves references within the prepared project, opens read-only/nonblocking, checks and reads the same descriptor, and closes it on every path. It covers hosted/inlined references, mounted composition HTML, directory copies and download mirrors. This addresses CodeQL #850.

Directory publication reads at most the remaining 2 MB source budget plus one byte from each checked descriptor, in chunks of at most 64 KB. It charges the resulting buffer lengths before any output write, so oversized or concurrently growing media cannot cause an unbounded allocation. Both directory layouts and download mirrors reuse those buffers, so replacement between discovery and open cannot bypass the cap, and later changes cannot alter mirrored bytes. The existing source-counting policy, exact-limit acceptance, mirror collision precedence and internal downloads directory aliases are preserved.

External file links and sibling-prefix mounted-composition escapes remain unresolved instead of publishing files outside the prepared project. Internal links and symlinked project roots still work. Asset hashing, MIME handling, empty-file behavior, reference reporting and output write semantics are preserved. The prepared project tree remains trusted against hostile concurrent parent-directory renames. Cache-write alerts #851/#852 are reserved for the next narrow batch.

Validation: all 254 script/catalog tests (188 Node + 66 Vitest), script typecheck, dependency builds, lint/format and signed hooks pass. The 24 focused cases cover pathname replacement, external paths, directory budget replacement, all-or-nothing rejection, exact-limit mirrors, internal aliases, FIFO, short descriptor reads, sparse oversized inputs, growth after fstat and descriptor cleanup. Three bounded-read cases fail on the previous PR head and pass on this revision; the earlier four budget/mirror regression witnesses remain green. The suite is registered in the existing Windows lane; fresh CI and Windows results are pending.

Optional Fallow audit remains red locally: complexity and duplication findings in the small filesystem routines and unchanged cache-write blocks. This revision removes two duplicate directory walkers and simplifies repeated missing-file handling. Remaining new routines are small, covered filesystem checks/traversal; the descriptor cleanup pattern resembles the engine reader, and the two unchanged cache-write blocks are covered by the next batch. No audit suppressions were added. Required checks, CodeQL and approval of the current commit remain merge gates.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Fallow audit report

Found 11 findings.

Duplication (2)
Severity Rule Location Description
minor fallow/code-duplication scripts/catalog-payload-assets.ts:255 Code clone group 1 (6 lines, 2 instances)
minor fallow/code-duplication scripts/catalog-payload-assets.ts:308 Code clone group 1 (6 lines, 2 instances)
Health (9)
Severity Rule Location Description
major fallow/high-crap-score scripts/catalog-payload-assets.ts:92 'localReferences' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
minor fallow/high-crap-score scripts/catalog-payload-assets.ts:121 'probableReferences' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
critical fallow/high-crap-score scripts/catalog-payload-assets.ts:171 'readProjectFile' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
major fallow/high-crap-score scripts/catalog-payload-assets.ts:221 'processAssets' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
minor fallow/high-crap-score scripts/catalog-payload-assets.ts:301 'out' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
minor fallow/high-crap-score scripts/catalog-payload-assets.ts:322 'hostedPaths' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score scripts/catalog-payload-assets.ts:335 'downloadMirrorPrefix' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
major fallow/high-crap-score scripts/catalog-payload-assets.ts:370 'hostItemDirectory' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
major fallow/high-crap-score scripts/catalog/catalog-payload-assets.file-race.test.ts:41 'beforeRead' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)

Generated by fallow.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The individual source reads are substantially improved. scripts/catalog-payload-assets.ts:142-183 rejects lexical escapes and resolved external links, opens read-only/nonblocking, checks and reads the descriptor, and closes on every path. The four callers now hash/inline/copy the bytes returned by that one owner; empty files, internal links, and symlinked project roots remain intact. The 15 new focused tests pass locally, as do changed-file lint/format. Exact-head Build and Typecheck are green; required Test/Windows and JavaScript CodeQL remain pending.

Blocker — the directory byte cap is still a separate pathname check from the bytes published. directoryBytes() at scripts/catalog-payload-assets.ts:344-355 stats every candidate, then hostItemDirectory() authorizes the whole copy at :368-369; only afterward do walk()/walkInto() reopen the paths through readProjectFile() at :372-406. A file replaced after its budget stat but before the descriptor open is therefore charged at the old size and published at the new size.

I reproduced this at the exact head with an instrumented test: a one-byte internal asset.png was replaced with a 2,000,001-byte file immediately after directoryBytes() obtained its stat. hostItemDirectory() returned /item/ and wrote the full 2,000,001-byte asset, bypassing MAX_HOSTED_DIRECTORY_BYTES. I removed the probe afterward. The current replacement witnesses swap only after readProjectFile() has opened its fd, so they cannot see the earlier budget→open window.

Please make the budget and copy consume the same checked bytes—e.g. collect { relativePath, bytes } once, sum bytes.length before writing anything, then publish those exact buffers (including the _downloads mirror mapping). Add a replacement witness between budget discovery and source open, and preserve the current all-or-nothing behavior for over-budget directories.

Non-blocking CI note: optional Fallow audit is red at this head with 16 findings, including critical complexity on readProjectFile and a 61-line clone with the existing descriptor reader. It is not in the required set, but the PR should either simplify/share the helper or explicitly account for that result before calling CI clean.

Verdict: REQUEST CHANGES
Reasoning: Per-file identity is pinned correctly, but directory publication still authorizes bytes using stale pathname sizes, so the same replacement race bypasses the advertised 2 MB budget.

— Magi

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The original budget/identity blocker is closed: hostItemDirectory() now collects each descriptor-checked buffer once, sums those exact lengths before any output write, and reuses the same map for original and _downloads mirror paths (scripts/catalog-payload-assets.ts:351-378). Internal aliases resolve to their real in-project prefix, external aliases avoid traversal, and the second publication loop preserves mirror-wins collision order. The new all-or-nothing, exact-limit, pre-open replacement, post-collection mutation, alias, and collision tests are well targeted. Focused suite is 20/20 and changed-file lint/format pass locally.

Blocker — an over-budget file is now fully buffered before the cap can reject it. readProjectFile() performs an unbounded readFileSync(fd) at scripts/catalog-payload-assets.ts:155-180; only after that allocation returns does hostItemDirectory() add bytes.length and compare against MAX_HOSTED_DIRECTORY_BYTES at :355-360. The old directoryBytes() stat path rejected a 2 MB+ file without reading it. Since HOSTED_EXTENSIONS includes .mp4 and .webm, a large catalog asset—or the same replacement attacker swapping in a huge sparse file before descriptor open—can OOM the script before the 2 MB guard executes. The new cap+1 test itself necessarily reads all 2,000,001 bytes; scaling that fixture exposes the regression.

Please make the directory collector's read bounded by the remaining budget: read at most remaining + 1 bytes from the checked descriptor (a fstat fast-reject is useful, but the bounded read must remain authoritative if the inode grows), signal over-budget without allocating the whole source, and keep reusing the accepted buffer for both output spellings. Add a test that proves an oversized/sparse source is never read beyond cap+1 while output remains all-or-nothing.

The optional Fallow tradeoff is adequately disclosed: the revision removes both duplicate walkers, reduces reader cyclomatic complexity 15→9, adds no suppression, and reserves unchanged cache-write clones for #851/#852. Its remaining red is not the blocker here.

Exact-head required checks are still running; the only completed failure is optional Fallow, already accounted for in the body.

Verdict: REQUEST CHANGES
Reasoning: The revised snapshot model fixes stale authorization and mirror drift, but enforcing the 2 MB output cap only after an unbounded read turns large or swapped-in media into a new memory-exhaustion path.

— Magi

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at the bounded-read head: both directory-publication blockers are closed.

readWithinBudget() at scripts/catalog-payload-assets.ts:155-168 reads the checked descriptor in ≤64 KiB chunks and stops after maxBytes + 1; readProjectFile() keeps that inside its existing descriptor finally at :171-199. hostItemDirectory() passes the remaining budget at :372-384, so accepted buffers total at most 2 MB, a sparse or growing source can consume at most cap+1 bytes, and the same accepted map remains authoritative for original and mirror writes. EOF, short reads, a zero-byte file at zero remaining budget, and inode growth all have the right result.

The expanded tests are strong: the 32 MB sparse file proves total actual reads stop at cap+1; the post-fstat growth case proves fstat is not the authority; the short-read case prevents truncation; the failure case pins descriptor cleanup; and the earlier all-or-nothing/mirror/alias/collision cases remain intact. I ran the focused suite (24/24) and changed-file lint/format; all pass.

The optional Fallow failure is adequately accounted for: no suppression, two duplicate walkers removed, and only one unchanged cache-write clone group remains alongside the small covered filesystem routines. It is not in the required set. Required Build/Typecheck/Windows are still running; exact-head required checks have no failures and remain landing gates with JavaScript CodeQL.

Verdict: APPROVE
Reasoning: Directory publication now enforces identity, output budget, and memory budget from the same bounded descriptor reads, then publishes only those captured buffers with preserved mirror semantics.

— Magi

@jrusso1020
jrusso1020 merged commit 7a2a691 into main Sep 6, 2026
46 of 47 checks passed
@jrusso1020
jrusso1020 deleted the fix/security-catalog-asset-files branch September 6, 2026 05:18
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.

2 participants