Skip to content

Gate the preview server hardening with a regression test - #32

Merged
stormer78 merged 3 commits into
v4from
sec-4045/regression-gates
Sep 13, 2026
Merged

Gate the preview server hardening with a regression test#32
stormer78 merged 3 commits into
v4from
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Targets v4.

#30 hardened the quartz build --serve preview server: both sockets bind
127.0.0.1 by default, the banner reports the address actually bound, and a
request path that escapes the output directory is refused before any
fs.existsSync call. Nothing tested any of it. This adds a test that runs the
real server and fails if any of that is undone.

quartz/cli/handlers.test.ts spawns quartz build --serve against a
two-page fixture site in quartz/cli/fixtures/serve, with no --host, so it
exercises the default. It drives the server over raw TCP, because an HTTP client
collapses .. out of a request target before it ever leaves the process and the
whole question here is what the server does with a target that still has it.

It asserts:

  • Loopback only. The server and the hot-reload WebSocket both answer on
    127.0.0.1, and neither answers on ::1 or on any non-loopback IPv4 address
    this machine has. listen(port) with no host binds ::, which answers on
    ::1 as well as on every other interface, so a refusal on ::1 is what tells
    the two binds apart — checked only after confirming ::1 works at all here,
    so a machine with IPv6 off cannot turn the probe into a free pass. The test
    fails if no off-loopback address is available to probe, rather than passing
    vacuously.
  • The banner does not lie. It reports http://127.0.0.1:PORT, the address
    the socket is on, not the hardcoded http://localhost:PORT printed before.
  • Escaping paths are refused, and the existence oracle is closed: the
    /trailing/ form aimed at a file that exists just outside the output
    directory, and the same form reached through a legitimate-looking prefix
    (/nested/../../…/), both get a 400; and a present sibling and a
    never-created one now return byte-identical status lines, where before one was
    a 302 and the other a 400.
  • Normalization, not a blanket reject. /../ normalizes back to the site
    root and is still served with a 200, as Harden the preview server, container image and deploy workflow #30 describes. Without this a change
    that simply 400s every path containing .. would pass the traversal
    assertions while breaking ordinary requests.
  • Ordinary requests are unaffected: / 200, /index 301, /nested/page 200,
    /no-such-page 404.

Which inputs actually gate, and which only look like they do

The first draft of this test asserted a 400 on six traversal targets. Four of
them pass with #30's guard removed, because serve-handler refuses them with a
400 of its own — /../outside-absent/, /../outside-present,
/../outside-absent, and a long ../ run at /etc/passwd. Asserting 400 on
those pins serve-handler and not the guard the test is named for. They are
gone, with a comment recording why, and the file keeps only the two forms the
guard alone can refuse.

The reason is worth stating, since it is not obvious from reading the handler:
path.posix.join(fp, "index.html") drops a leading .. from an absolute path,
so the /trailing/index.html probe never escapes. It is
path.posix.join(argv.output, base) in the same branch that does, because there
the .. sits after the root rather than at the start. So the escape needs the
/trailing/ form and a file that exists outside the root — which is exactly
the 302 that made it an oracle.

Proof that it fails when the fix is reverted

Each guard was reverted on its own, in a single shell invocation with a trap
restoring the file on exit, and the test run against it:

reverted result
server.listen(argv.port) / WebSocketServer({ port }), banner kept banner test and loopback test fail; path tests pass
the normalize-and-reject block only escape test fails 302 !== 400, oracle test fails 302 Found !== 400 Bad Request, /../ fails 400 !== 200; bind tests pass
the honest banner only, loopback bind kept banner test alone fails, http://localhost:PORT
args.js host default back to 0.0.0.0 banner test and loopback test fail

Each revert isolates one guard and fails only the assertions belonging to it,
so no assertion is passing for a neighbouring reason. git diff v4 on the
touched files is empty after each run; no revert is left in the branch.

Hermetic, and quick

Every address the test connects to is an address of the machine it runs on —
loopback, or one reported by os.networkInterfaces() — asserted rather than
assumed, so a later edit that hardcodes an address trips the check. Nothing
reaches the network, with the fix in place or without it. That holds in the
reverted runs too, which is the case that matters: a wildcard bind makes the
server reachable on the host's own LAN address, and that address is the one
probed.

The server is spawned in its own process group and the group is SIGKILLed in
after, so the build workers cannot outlive the run holding the ports. Ports
are taken from the kernel rather than hardcoded. Scratch files live in a
mkdtemp directory that teardown removes, so the test writes nothing inside the
repository.

Whole suite: 78 tests, 1.9s wall (73 before, 5 new). The reverted runs are
the same, 1-2s each — a failing run does not hang or stall.

Verification

  • npm test: 78 tests, all passing.
  • npx quartz build succeeds; 216 files emitted from 42 input files.
  • tsc --noEmit and prettier --check produce exactly the same output as on
    v4, compared side by side against a clean origin/v4 worktree: the one
    pre-existing moduleResolution=node10 deprecation and the same 44
    pre-existing Markdown formatting warnings. npm run check fails on v4
    today and fails identically here; unchanged by this branch.

Two things a reviewer should know

ci.yaml is not a required check, so nothing here blocks a merge yet. The
default branch v4 has no branch protection, and the only ruleset on it is the
organization's DCO check. The test job added in #29 runs on every pull
request and reports, but a merge is not gated on it, so a red run does not stop
anything. The same gap makes the .github/CODEOWNERS added in #29 advisory:
without a rule requiring code-owner review it only requests reviewers. Both need
a repository or organization setting that a pull request cannot make. Adding
test to the default branch's required checks is what turns this test into a
merge gate.

--host 0.0.0.0 is deliberately not tested. Asserting that the escape hatch
really does bind every interface means binding every interface in CI. The
loopback default and the banner are covered instead; the warning printed on a
non-loopback bind is not.

quartz/cli/handlers.js is inherited from jackyzha0/quartz and already
diverges from it after #30. This branch adds only a test file and fixtures, so
it adds no further divergence in the handler itself.

Drives the real `quartz build --serve` over raw sockets and checks the two
preview server issues fixed in #30: the listener bound every interface while
the banner claimed localhost, and the redirect probes ran `fs.existsSync` on
the un-normalized request path.

Committed as-is before review so it is not left only in a working tree.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Three corrections, all found by measuring the server with the #30 guards
removed rather than reasoning about them.

The raw client half-closed the socket with `socket.end(request)`. Node's
server tears the connection down when it sees the FIN, so every response
read back empty and every status compared `NaN`, which failed even with the
fix in place. It writes without ending now and lets `Connection: close`
finish the exchange.

`/index` is a 301, not a 200 -- serve-handler redirects an explicit /index
to /.

Four of the six traversal targets passed with the guard removed, because
serve-handler refuses them with a 400 of its own: `/../outside-absent/`,
`/../outside-present`, `/../outside-absent` and a long `../` run at
/etc/passwd. Asserting 400 on those pins serve-handler rather than the
guard, so they are gone, with a comment recording why. What is left is the
`/trailing/` form aimed at a file that exists outside the root, which
answered 302 before the fix, and the existence oracle itself -- the present
and absent siblings now answer identically.

`/../` is asserted to still serve the site root, so a blanket reject of
every path containing `..` cannot pass in place of normalizing.

Scratch files move to a mkdtemp directory instead of `quartz/.quartz-cache`,
so the test writes nothing inside the repository.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The comment on the test step listed only the sanitize test. Both security
regression tests now run there, so both are named.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested review from a team as code owners September 13, 2026 05:06
@stormer78
stormer78 merged commit b9b3a14 into v4 Sep 13, 2026
2 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 13, 2026 05:16
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