fix(elpx): converge the three entry-path normalizers on validate-never-rewrite - #96
Merged
Merged
Conversation
…r-rewrite The PHP, TypeScript and Service Worker implementations of the entry-path rule disagreed. PHP rejected any `.`/`..` segment and kept the empty segment from a doubled slash; TS and the SW mirror resolved dot segments and collapsed doubled slashes. Never a traversal hole — all three rejected `../escape` — but a package containing `a/b/../c` rendered in the browser and 404'd from the PHP asset route and the preview provider, and the docblock on `normalizeEntryPath` claimed the two matched. All three now validate without rewriting: an entry path is accepted only when it is already canonical, and it comes back byte-identical. Rejected: the empty string, NUL bytes, backslashes, and any empty, `.` or `..` segment. Rewriting cannot be made consistent. Entry names are looked up verbatim — `ZipArchive::statName()` matches central-directory names byte-for-byte, and the browser keys its map by the stored name — so resolving `a/b/../c` to `a/c` addresses a different entry than the one requested, and an archive can contain both. Dot segments are unreachable anyway: URL parsers apply RFC 3986 §5.2.4 dot-segment removal before a request is dispatched. `resolveRelativeEntry` still resolves `./` and `../`, because an href inside package HTML may legitimately contain them; it resolves first and validates the result. A leading slash in an href now addresses the package root. One shared table, `tests/fixtures/entry-path-vectors.json`, is loaded by both suites. The Vitest side also evaluates the shipped Service Worker file in a `node:vm` context so the mirror is tested rather than transcribed.
Names the record after this pull request, which is the tracking number here because issues are disabled on the repository, and pins the identifier in the three implementations, the shared vector table, AGENTS.md and the elpx-package-safety skill. The skill documented the divergence as an open defect; it now documents the converged rule, why validation cannot be a rewrite, and that the Service Worker copy stays an inline mirror.
Contributor
Preview this PR in the Nextcloud PlaygroundA fresh Nextcloud boots in your browser with this branch's eXeLearning editor: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
.elpxentry-path rule had three implementations — PHP, TypeScript, and the Service Worker's inline mirror — and they did not agree. This converges them and records the decision as ADR-96-01.What diverged
Measured against
origin/mainat7520972, on PHP 8.5.9 and Node 26.6.0:a/b/ca/b/ca/b/c../escapenullnulla/b/../cnulla/ca/./bnulla/ba//ba//ba/bThis is a consistency fix, not a traversal fix. Every implementation rejected
../escapeand none escaped the package root. The symptom was that a package containinga/b/../crendered in the browser but 404'd from the PHPAssetControllerand the preview provider — and the docblock onnormalizeEntryPathclaimed it "matches the rule used by the PHP-sideZipEntryService", which was false.What was adopted, and why
Reject, and rewrite nothing. An entry path is accepted only when it is already canonical, and it comes back byte-identical. Rejected: the empty string, any NUL byte, any backslash, and any empty,
.or..segment — which covers leading, doubled and trailing slashes.normalizeEntry(x)is eitherxornull, never a third string.Resolving was the tempting option — more permissive, keeps working what works today — but it does not actually converge anything:
Entry names are looked up verbatim.
ZipArchive::statName()matches central-directory names byte-for-byte, and the browser keys its map by the stored name. Against an archive built with four literal names:a/b/../canda/care two different entries with different contents in one archive. A resolving normalizer asked for the first serves the second. Resolving in PHP would not even fix the reported 404 — the route would look upa/c, which is a different entry or absent.Dot-segment entries are unaddressable anyway. URL parsers apply RFC 3986 §5.2.4 dot-segment removal before a request is dispatched, and percent-encoding does not evade it:
Both the SW route and the asset route address entries through a URL path, so an entry whose stored name contains a dot segment can never be requested, whatever the normalizer decides.
Rejecting also makes the rule one sentence long, which is what matters when reviewing security code: the accepted set is a predicate and the return value is the input, so the three implementations agree by construction.
What could break
ZipReadError/UNSAFE_ENTRY) where the browser previously rendered it. Every.elpand.elpxavailable locally — this repo's fixtures plus the editor's test corpus — was scanned: 224 packages, 43 539 entries, 0 non-canonical names. JSZip, Archiver and Python'szipfileall emit forward-slash relative names, and the ZIP spec requires it (APPNOTE §4.4.17.1: a stored name "MUST NOT contain a drive or device letter, or a leading slash", and "All slashes MUST be forward slashes").readEntry()no longer tolerates a leading slash on a caller-supplied name. All in-tree callers pass canonical names.resolveRelativeEntrystill resolves./and../, because an href inside package HTML may legitimately contain them: it resolves first (RFC 3986) and validates the result. A leading slash in an href now addresses the package root instead of producing a doubled slash that the old normalizer silently collapsed.The shared vector table
tests/fixtures/entry-path-vectors.jsonis one file loaded by both suites —tests/js/paths.test.tsandtests/Unit/Service/ZipEntryServiceTest.php(via#[DataProvider]). No build step. The JS suite also evaluates the shippedsrc/sw/exelearning-sw.jsin anode:vmcontext with a stubself, so the mirror is tested rather than transcribed; the mirror stays inline, as it must.index.htmlindex.htmla/b/ca/b/chtml/page-1.htmlhtml/page-1.htmlcontent/Página 1.html..../x..../x../escapenullhtml/../../etcnulla/b/../cnull..segmenta/b/..null..segmenta/./bnull.segment./anull.segment.null...null..a//bnull....//xnull/leadingnull/null//nulltrailing/nulla\bnull\leadingnullnulla<NUL>bnull<NUL>nullReverting just the empty-segment check in the Service Worker fails 6 of these, so the guardrail detects a real divergence rather than a transcribed one.
Verification
Run locally on macOS, PHP 8.5.9, Node 26.6.0:
Not verified locally: the Nextcloud server matrix (CI boots a real server; nothing here touches DI, routes or
info.xml).ADR
docs/architecture/adr/ADR-96-01-validate-entry-paths-instead-of-rewriting-them.md—status: Proposed..agents/skills/elpx-package-safety/SKILL.mddocumented the divergence as an open defect; it now documents the converged rule and points at the ADR.