Skip to content

Close the remaining security gaps in apps, the dev server and the platform - #384

Merged
Brooooooklyn merged 51 commits into
security/1c-proxy-token-rotationfrom
security/1d-hardening
Oct 8, 2026
Merged

Brooooooklyn merged 51 commits into
security/1c-proxy-token-rotationfrom
security/1d-hardening

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Attack surface before this PR

The PRs below this one fixed the project token. This PR closes the smaller holes that are left. Each line is one path that someone outside the team could use.

1. Apps built with Void

any website ──form POST / WebSocket──► app route ──► browser adds the Lax session cookie ──► runs as the victim ✗
            (on hosted, every other *.void.app app is the same site)
any website ──link──► /en//evil.com  (rule /en/* → /:splat) ──► Location: //evil.com ✗
any website ──link──► ?next=javascript:…  ──► router.visit() ──► script runs on the app origin ✗
anyone ──► /api/auth/sign-in, no limit ──► password guessing ✗   (Workers never set NODE_ENV=production)
anyone ──► POST /__void/prerender-paths on a direct deploy ──► runs every getPrerenderPaths() ✗
visitor ──► /page?x=<payload> on a cold ISR cache ──► stored under the shared key ──► every visitor gets it ✗
visitor ──► input becomes a route param ".." ──► /api/posts/.. → /api/ ──► another route, with cookies ✗
visitor ──► a deferred prop or void/live call fails ──► raw error text (SQL, hostnames) in the reply ✗
visitor ──► basicAuth() with an unset password ──► an empty password gets in ✗

2. The platform

new signup ──► project "hostmaster" ──► gets hostmaster@<mail domain> ──► passes a CA email check ✗
new signup ──► reuses a deleted project's slug ──► sees the old owner's logs ✗
new signup ──► asks to email someone@corp ──► the corp link scanner opens the link ──► consent granted ✗
new signup ──► collectLog:false or cf-aig-* headers ──► Workers AI calls not counted ──► past the AI limit ✗
someone who knows a project id ──► platform AI Gateway, metadata projectId=<victim> ──► victim's AI blocked ✗
old owner ──► fills the 1-year cache for /assets/main-<hash>.js ──► host moves ──► visitors get old code ✗
anyone ──► POST 8 MiB to a public proxy host, no credential ──► the proxy reads it all before it checks ✗
anyone ──► GitHub/Google account with an admin's verified email ──► signs in as that admin ✗
admin suspends an abusive account ──► its cron jobs and queue consumers keep running ✗
new signup ──► deploys thousands of cron schedules ──► floods the shared cron queue and scheduler ✗
visitor ──► log text with terminal escapes ──► void logs / operator CLI runs them in the admin's terminal ✗
a page on the same site ──► frames an API or dashboard page ──► clickjacking ✗

3. The local dev server

Anyone who can reach it: a --host peer, another local account, or a page on localhost.

GET /.void/dev-trigger-token      ──► the token that fires crons and reads the dev inbox ✗
GET /.void/drizzle.config.json    ──► database URL with password (the production URL with --remote) ✗
GET <file>?island-style&lang.css  ──► the Svelte style loader reads any file, past fs.allow ✗
POST /__void/scheduled  Host: <ip> ──► the Analog dev server forwards the request to that IP ✗

4. Docs examples that apps copy

upload example ──► stored under the browser's file name, served inline ──► an uploaded SVG runs script ✗
cache() as global middleware ──► a signed-in page is cached ──► the next visitor gets it ✗

What this PR changes

Apps

Risk Now
Cross-site cookie Unsafe methods and WebSocket upgrades from an origin that is not the app and not in trustedOrigins run signed out.
Auth guessing Better Auth's default rate limits are on in deploys, keyed by cf-connecting-ip.
Open redirect A capture that starts with / or \ gives one slash: Location: /evil.com.
router.visit() Other-origin GET does a full page load. Other schemes throw. Other-origin POST throws before it sends.
.. params Typed clients and useForm() throw for . and .. segments and a leading //.
Prerender Path discovery answers only the build-time runner. Paths that leave the output folder fail.
ISR A cold page with an unlisted query param renders live and is not stored. Allowlisted values get their own key.
Error text Production deferred props and void/live send a fixed message and log the error.
basicAuth() An empty password fails closed.
Named actions Only own function properties of actions count.

Platform

Risk Now
CA mailbox names New projects cannot be named administrator, hostmaster or webmaster.
Old logs on a reused slug The log view starts at the project's creation time, by both log time and invocation start.
Link scanners The email link shows a Confirm button. Only the button grants consent.
Workers AI usage The platform sets the gateway metadata and logging, drops caller metadata, logging, cost and auth cf-aig-* headers, and counts only rows with a tag signed by the platform. A caller cache key gets the project as a prefix.
Hashed-asset cache The edge cache key includes the project id.
Proxy body A credential is checked before the body is read. A public request with no credential gets 401 unread.
Admin email match An email match never signs in to or links an administrator account.
Suspension Admin suspension also pauses cron jobs and queue relays. Restore resumes both. Queue messages that ran out of retries during the suspension are gone.
Cron flood A platform deploy can use up to 100 distinct cron expressions and 1,000 schedules.
Terminal escapes void logs and operator CLI output drop control characters.
Framing API, admin and dashboard HTML pages send frame-ancestors 'none'.

Dev server

.void/dev-trigger*, .void/drizzle.config.json, Nitro dev bundles, .astro/dev.log ──► 404, in any URL form
virtual:void-routes in the browser environment ──► refused (it holds the token)
Svelte island style loader ──► builds only
Analog trigger proxy ──► always the dev server's own address
db studio / db push ──► database URL in a private temp folder, removed after

Decisions

Choice Why
Cross-site requests run signed out, not 403 Public routes keep working. Only the cookie is ignored.
No ownership TXT check for custom domains Same as Vercel: the CNAME is enough for a normal add. One more DNS record for every new domain costs more than the leftover-CNAME risk below.
Provider-native AI calls are not counted They bill to the app's own provider key, as the AI guide says.
Accept the one-time AI usage restart Only current, trusted users gain, at most one month's allowance.
Cron pause fails open on a KV error A KV fault must not stop every tenant's cron jobs.
Auth rate limits per Worker instance by default No new storage to set up. storage: 'database' shares one count.
Self-hosted installer order unchanged A first install needs the API before the gateway's service binding.

What people see

Who What changes
App developers A frontend on another origin must be in auth trustedOrigins.
Auth endpoints return 429 after too many tries. Change it with rateLimit in auth.ts. The auth guide shows what to do behind a proxy and for end-to-end tests.
ISR pages that read a query param must list it in routing.revalidateQueryAllowlist, or cold requests render live.
Unsafe router.visit() URLs and ./.. route params now throw.
basicAuth() with an empty password returns a server error.
Platform users New projects cannot use the names above.
Email recipients click Confirm.
A deploy with more than 100 distinct cron expressions or 1,000 schedules is refused.
On hosted apps, the app's own AI gateway metadata and collectLog, and its logging, metadata, cost and auth cf-aig-* headers, are ignored. Cache, timeout and retry headers still apply. Provider-native calls with the app's own key still do not count toward the platform allowance.
On hosted apps, ai.toMarkdown() image descriptions now count toward the AI allowance. Text conversions stay free.
Admins and operators Suspend also stops cron jobs and queue batches. Held queue messages retry about once an hour and are dropped when their queue runs out of retries: after about 3 hours by default, at once with maxRetries: 0. The suspend preview (CLI) and the web Suspend confirmation say so.
An administrator signs in only with a login already linked to them. Another login with the same email gets "This login is not linked" and is told to sign in again with the GitHub or Google account used before.
void platform worker rollback-all rolls the API back before the email gateway.

Rollout

hosted deploy: proxy ──► email gateway ──► API (migrations) ──► tail ──► dispatch ──► dashboard

The email gateway now goes before the API, so the Confirm button works during the deploy. The self-hosted installer keeps its order, because the gateway's service binding needs the API to exist. During a self-hosted upgrade, a Confirm press in the few seconds between the two gets a 404 and works on retry.

One-time effects:

  • Counted Workers AI usage restarts for the current billing period. An account already at its AI limit can use up to one more month's allowance until its next period. Changing JWT_SECRET does this again.
  • The hashed-asset edge cache and cached AI replies under custom keys miss once.
  • Accounts that an admin suspended before this release keep their background work until the next change to them. Re-apply their plan: void platform user plan <user-id> <current-plan> --yes.

Before merge:

  • On hosted Postgres, check that no active deployment is over the cron limits (query below).
  • On staging, check that an ai.run() call shows the signed tag in AI Gateway logs.
  • On staging, convert one image and one PDF with ai.toMarkdown(). Check that the image adds AI usage and the PDF adds none.
Cron limits query
SELECT p.id, p.slug,
       COUNT(DISTINCT s->>'cron') AS cron_expressions,
       COUNT(*) AS schedules
FROM projects p
JOIN deployments d ON d.id = p.active_deployment_id
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(d.manifest::jsonb -> 'schedules', '[]'::jsonb)) AS s
WHERE d.manifest IS NOT NULL
GROUP BY p.id, p.slug
HAVING COUNT(DISTINCT s->>'cron') > 50 OR COUNT(*) > 500
ORDER BY cron_expressions DESC, schedules DESC;

Known limits (on purpose)

  • Leftover CNAMEs. Cloudflare accepts a CNAME to the platform as proof of ownership. If a host still points at the platform after its owner removed it, the first project that adds it can serve it with a valid certificate. Vercel and Netlify have the same limit. Owners must delete the DNS record when they remove a domain.

    owner removes shop.example.com from Void, keeps  shop.example.com CNAME → platform
    new signup ──► void domain add shop.example.com ──► Cloudflare sees the CNAME ──► live ✗
    
  • Suspension pauses queues by retrying each batch about once an hour, and each retry uses one of the queue's retries. A batch that runs out of retries is dropped: after about 3 hours with the default of 3 retries, and at once with maxRetries: 0. Restore a wrong suspension quickly. Keeping messages without using retries (Cloudflare's pause delivery) is a follow-up; it should also cover the platform-wide traffic gate on main, which drops messages the same way after about 15 minutes. Usage-limit suspension does not pause cron jobs or queues.

  • Cron pause lets a fire through when the suspension marker cannot be read, so a KV fault does not stop every tenant's cron jobs.

  • Cron and queue runs are still not metered. A hanging cron handler can still hold a slot in the shared cron queue.

  • Tenant Workers can still make the proxy read up to 8 MiB before their project token is checked. The caller is known and can be suspended.

  • Better Auth plugins that add trusted origins are not seen by the cookie check.

  • Time zone. The log bound reads projects.created_at as UTC, like other platform code. A self-hosted database with a non-UTC session shifts it.

Testing

Command Result
vp run check pass
vp lint, vp fmt --check pass
vp run build, then vp test run (on main cf45d55db) pass: 705 files passed, 1 skipped; 13691 tests passed, 30 skipped

🤖 Generated with Claude Code

@Brooooooklyn
Brooooooklyn added this pull request to stack #385 October 7, 2026 19:58
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://security-1d-hardening-void-cloud.voidzero-docs.workers.dev (commit 6294a67)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://136b793c-void-cloud.voidzero-docs.workers.dev 6294a67 2026-10-08T12:47:32.607Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e014b6a6-void-cloud.voidzero-docs.workers.dev 85df620 2026-10-08T12:31:27.966Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://14249ff1-void-cloud.voidzero-docs.workers.dev 811338f 2026-10-08T10:35:25.756Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://04f29bd7-void-cloud.voidzero-docs.workers.dev ed42747 2026-10-08T10:20:14.477Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://64e7a667-void-cloud.voidzero-docs.workers.dev 5fb9562 2026-10-08T10:05:54.844Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e037e719-void-cloud.voidzero-docs.workers.dev 71f5d05 2026-10-08T09:54:34.441Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://8a3b9dee-void-cloud.voidzero-docs.workers.dev 1cb95de 2026-10-08T07:36:52.696Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://a89e2195-void-cloud.voidzero-docs.workers.dev e79a32b 2026-10-08T07:20:55.087Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://f756063a-void-cloud.voidzero-docs.workers.dev bbc3703 2026-10-08T07:10:19.346Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://63652a97-void-cloud.voidzero-docs.workers.dev 9c638ee 2026-10-08T04:36:21.868Z Visit the dashboard ↗

View all previews: View all previews ↗

@Brooooooklyn
Brooooooklyn marked this pull request as ready for review October 8, 2026 02:58
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from a97e773 to 79b01ac Compare October 8, 2026 02:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T12:51:44.405090Z 6294a67 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

chatgpt-codex-connector[bot]

This comment was marked as outdated.

@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 79b01ac to f91d96f Compare October 8, 2026 03:28
chatgpt-codex-connector[bot]

This comment was marked as outdated.

@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from f91d96f to 4877a4d Compare October 8, 2026 03:41
@Brooooooklyn
Brooooooklyn marked this pull request as draft October 8, 2026 04:11
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 4877a4d to 4d47300 Compare October 8, 2026 04:23
@Brooooooklyn
Brooooooklyn marked this pull request as ready for review October 8, 2026 04:32
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 4d47300 to 9c638ee Compare October 8, 2026 04:34
chatgpt-codex-connector[bot]

This comment was marked as outdated.

@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch 2 times, most recently from bbc3703 to e79a32b Compare October 8, 2026 07:19

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e79a32bdfb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread platform/packages/api/src/index.ts
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from e79a32b to 1cb95de Compare October 8, 2026 07:35
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 1cb95de to 71f5d05 Compare October 8, 2026 09:53

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71f5d05a63

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/runtime/better-auth-shared.ts
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 71f5d05 to 5fb9562 Compare October 8, 2026 10:05

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fb9562f3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread platform/packages/dispatch/src/index.ts
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 5fb9562 to ed42747 Compare October 8, 2026 10:19

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed4274767e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/pages/navigation-engine.ts Outdated
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from ed42747 to 811338f Compare October 8, 2026 10:34
Brooooooklyn and others added 3 commits October 8, 2026 20:28
Deployed Cloudflare Workers and Node, Bun and Deno servers no longer run
getPrerenderPaths() for outside requests to POST /__void/prerender-paths.
The prerender runner marks its own request with a __VOID_PRERENDER binding
(Miniflare) or Hono env argument (Node); unmarked requests are ordinary app
requests and go through env validation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A query such as ?constructor, ?valueOf or ?__proto__ no longer picks an
Object.prototype member as the page action; it gets the usual
"Unknown action" 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rule such as '/en/*' -> '/:splat' no longer turns /en//evil.com into a
protocol-relative Location header. When a path destination's capture starts
with a slash or backslash, the result is collapsed to a single leading slash.
Destinations written as '//host/...' or 'https://...' are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brooooooklyn and others added 24 commits October 8, 2026 20:45
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oject owner

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r gateway headers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts log view

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cloudflare accepts a CNAME to the SaaS zone as hostname ownership, so the
per-host TXT does not stop a leftover or wildcard CNAME from activating a
hostname. The comment and design note now describe that trade-off instead of
claiming the TXT blocks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Provider-native calls through the platform proxy dropped every caller
cf-aig-* header, so apps lost AI Gateway caching, timeouts and retries.
Pass those headers through and scope a caller cache key to the project,
in the same format ai.run uses. Metadata, logging, cost, gateway auth,
stored-key and unknown gateway headers stay dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Dropping only the ESC byte left stray text in log output, such as "[K"
after a progress line or the hidden URL of a terminal hyperlink, and
colon-separated truecolor codes lost their color. Complete CSI and OSC
sequences are now removed whole and SGR codes with colons are kept;
incomplete sequences still lose only their control characters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A live hook that throws now answers "live: internal error." so
production clients never see internal details. In `void dev` that also
hid the message from developers, unlike deferred props and the page
error renderer. The worker entry compiled by the Vite dev server now
turns on the real message; builds never do, so production keeps the
fixed text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Deployed Cloudflare apps now apply Better Auth's default rate limits per
client IP. A server that calls auth for many users shares one IP, and
tests that sign in often hit the sign-in limit. Show a secret-header rule
that skips the limits only for a trusted caller, and say that a proxy
using it must limit the requests it forwards.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the security/1d-hardening branch from 85df620 to 6294a67 Compare October 8, 2026 12:46
@Brooooooklyn
Brooooooklyn merged commit abf6380 into main Oct 8, 2026
20 checks passed
@Brooooooklyn
Brooooooklyn deleted the security/1d-hardening branch October 8, 2026 13:00
Brooooooklyn added a commit that referenced this pull request Oct 8, 2026
## Problem

`main` CI fails on `test (windows-latest, 2/2)` on every push since
#384:

```
dev-trigger-secret-guard.test.ts > still serves other files under .void
AssertionError: expected 403 to be 200
```

The test is not flaky. It never passed on Windows. #384 added it, and
PRs only run Windows CI with the `test: windows` label.

## Cause

```
tmpdir() on the Windows runner = C:\Users\RUNNER~1\AppData\Local\Temp   (8.3 short name)
        │
        ▼
realpathSync (JS)  ── keeps RUNNER~1 ──►  test root
        │
GET /.void/routes.d.ts
        │
        ├─ Void guard: no secret path → next()
        └─ Vite static step → isFileLoadingAllowed()
                 if (isWindows && looksLikeWindowsShortNamePath(path)) return false
                 → 403
```

Vite 8.3 refuses every file under a short-name path on Windows. This is
Vite's own rule. The Void guard is correct: all the 404 cases in the
file pass on Windows.

## What this PR changes

```
realpathSync(tmp)  →  realpathSync.native(tmp)
C:\Users\RUNNER~1\…     C:\Users\runneradmin\…
```

`realpathSync.native` expands the short name. `plugin-env-schema.ts`
already uses it for the same reason, and its tests pass on Windows.

## Testing

| Check | Result |
| --- | --- |
| `vp test run
packages/void/test/integration/dev-trigger-secret-guard.test.ts` (macOS)
| 15/15 pass |
| Windows simulation with a `RUNNER~1` root | 403 before, 200 with the
long root |
| `windows-latest` CI on this PR (`test: windows` label) | the real
check |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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