Skip to content

Commit ea45912

Browse files
hyfdevBrooooooklyn
andauthored
fix(config): reject protocol-relative rewrite/fallback destinations in schema (#225)
Editors now flag a protocol-relative `routing.rewrites`/`routing.fallbacks` destination (e.g. `//evil.com/x`) instead of silently accepting a config the build hard-rejects — closing a cross-origin-proxy footgun at author time. ## The bug The editor JSON schema validated rewrite/fallback destination values with `^/[^?]*$` — "starts with `/`, no `?`". That permits a *second* leading slash, so `"//evil.com/x"` matched. The runtime rejects it (`assertNotProtocolRelative` in `config.ts`, called at the Rewrite and Fallback sites) because `new URL("//host/x", base)` resolves to `https://host/x`, silently turning an internal rewrite into a cross-origin proxy. So an editor using `$schema` showed no warning for a destination the build rejects. ## Expected-correct behavior The schema should match the runtime: a rewrite/fallback destination must start with a single `/` (internal path only), not `//`. ## The fix Forbid the second leading slash in both dest patterns: `^/[^?]*$` → `^/(?!/)[^?]*$`. Verified byte-for-byte equivalent to the runtime predicate across the destination space (including correctly rejecting external `https://` dests, which rewrites/fallbacks don't allow). ## Scope note This is the only place the `^/[^?]*$` pattern appeared (rewrites + fallbacks share it). `routing.redirects` is intentionally **not** changed: its destination legitimately allows external `https://` URLs, so it uses a different (looser) schema shape and never carried this pattern; its `//` protection stays in the runtime (already tested). ## Tests Added a parity test in `config.test.ts` (same style as the existing `sourceDir` / `redirect-status-enum` parity tests): asserts the dest pattern accepts `/en/docs`, `/`, `/index.html` and rejects `//evil.com/x`, `//`, `///x`, `/x?a=1`, `en/docs` — failing on `origin/main` and passing after. The runtime `//` rejection is itself already covered (config.test.ts). Local gate: `config.test.ts` 159/159; full `--project unit` zero-collateral; schema.json valid JSON; typecheck + lint + fmt clean. No existing `void.json` / doc example uses a now-rejected destination. Prepared with AI assistance. Co-authored-by: LongYinan <lynweklm@gmail.com>
1 parent 0287ae9 commit ea45912

2 files changed

Lines changed: 18 additions & 2 deletions

File tree

‎packages/void/schema.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,15 @@
216216
"type": "object",
217217
"description": "URL rewrites. Keys are source URL patterns (path-only `/path` or host-scoped `https://host/path`); values are destination paths. Serves content from the destination without changing the browser URL. Destinations must start with \"/\" and must not contain \"?\" — the incoming request's query string is preserved automatically.",
218218
"patternProperties": {
219-
"^(/|https://)": { "type": "string", "pattern": "^/[^?]*$" }
219+
"^(/|https://)": { "type": "string", "pattern": "^/(?!/)[^?]*$" }
220220
},
221221
"additionalProperties": false
222222
},
223223
"fallbacks": {
224224
"type": "object",
225225
"description": "Fallback rewrites. Same shape as `rewrites`, but only fire when no static asset matched the request. Useful for SPA shells or default-locale catch-alls without pre-empting real routes. Destinations must start with \"/\" and must not contain \"?\".",
226226
"patternProperties": {
227-
"^(/|https://)": { "type": "string", "pattern": "^/[^?]*$" }
227+
"^(/|https://)": { "type": "string", "pattern": "^/(?!/)[^?]*$" }
228228
},
229229
"additionalProperties": false
230230
}

‎packages/void/test/unit/config.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,22 @@ describe('readConfig redirects', () => {
653653
}
654654
});
655655

656+
it('schema.json rewrite/fallback dest pattern rejects protocol-relative "//"', () => {
657+
const schema = JSON.parse(readFileSync(join(import.meta.dirname, '../../schema.json'), 'utf8'));
658+
for (const field of ['rewrites', 'fallbacks']) {
659+
const destPattern = new RegExp(
660+
schema.properties.routing.properties[field].patternProperties['^(/|https://)'].pattern,
661+
);
662+
// Runtime accepts internal paths and rejects protocol-relative dests + query strings.
663+
for (const dest of ['/en/docs', '/', '/index.html']) {
664+
expect(destPattern.test(dest)).toBe(true);
665+
}
666+
for (const dest of ['//evil.com/x', '//', '///x', '/x?a=1', 'en/docs']) {
667+
expect(destPattern.test(dest)).toBe(false);
668+
}
669+
}
670+
});
671+
656672
it('publishes schema.json as a package file and subpath export', () => {
657673
const packageJson = JSON.parse(
658674
readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'),

0 commit comments

Comments
 (0)