diff --git a/packages/void/schema.json b/packages/void/schema.json index 63d2c6b5b..684196109 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -216,7 +216,7 @@ "type": "object", "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.", "patternProperties": { - "^(/|https://)": { "type": "string", "pattern": "^/[^?]*$" } + "^(/|https://)": { "type": "string", "pattern": "^/(?!/)[^?]*$" } }, "additionalProperties": false }, @@ -224,7 +224,7 @@ "type": "object", "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 \"?\".", "patternProperties": { - "^(/|https://)": { "type": "string", "pattern": "^/[^?]*$" } + "^(/|https://)": { "type": "string", "pattern": "^/(?!/)[^?]*$" } }, "additionalProperties": false } diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index f41504d77..d41f60fb7 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -653,6 +653,22 @@ describe('readConfig redirects', () => { } }); + it('schema.json rewrite/fallback dest pattern rejects protocol-relative "//"', () => { + const schema = JSON.parse(readFileSync(join(import.meta.dirname, '../../schema.json'), 'utf8')); + for (const field of ['rewrites', 'fallbacks']) { + const destPattern = new RegExp( + schema.properties.routing.properties[field].patternProperties['^(/|https://)'].pattern, + ); + // Runtime accepts internal paths and rejects protocol-relative dests + query strings. + for (const dest of ['/en/docs', '/', '/index.html']) { + expect(destPattern.test(dest)).toBe(true); + } + for (const dest of ['//evil.com/x', '//', '///x', '/x?a=1', 'en/docs']) { + expect(destPattern.test(dest)).toBe(false); + } + } + }); + it('publishes schema.json as a package file and subpath export', () => { const packageJson = JSON.parse( readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'),