diff --git a/src/content/asset-url.ts b/src/content/asset-url.ts index 70916d1..a437e6d 100644 --- a/src/content/asset-url.ts +++ b/src/content/asset-url.ts @@ -6,7 +6,7 @@ const normalizeAssetBase = (base: string): string => { if ( !base.startsWith('/') || base.startsWith('//') || - /[\\%?#]/u.test(base) || + /[\u0000-\u001f\u007f\\%?#]/u.test(base) || base.split('/').some((segment) => segment === '.' || segment === '..') ) { throw new Error('Asset URL base must be a safe absolute path.'); diff --git a/src/content/schemas.ts b/src/content/schemas.ts index 9b3385c..36f9198 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -208,8 +208,7 @@ const siteConfigSchema = z.object({ canonicalUrl: httpUrl, buildId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/, 'Build ID must be a safe immutable identifier.'), }).strict().superRefine((site, context) => { - const url = new URL(site.canonicalUrl); - if (site.environment === 'preview' && (url.hostname !== 'portfolio.localhost' || url.port || url.pathname !== '/')) { + if (site.environment === 'preview' && site.canonicalUrl !== 'https://portfolio.localhost/') { context.addIssue({ code: 'custom', message: 'Preview canonical URL must be exactly https://portfolio.localhost/.', path: ['canonicalUrl'] }); } if (site.environment === 'production') { diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 2bbcf47..09ef634 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -60,6 +60,17 @@ describe('content schemas', () => { })).toThrow(/production hostname is not approved/i); }); + it('rejects preview URL normalization aliases instead of accepting their normalized value', () => { + for (const canonicalUrl of [ + 'https://portfolio.localhost/.', + 'https://portfolio.localhost/%2e', + 'https://portfolio.localhost/?', + ]) { + expect(() => parseSiteConfig({ environment: 'preview', canonicalUrl, buildId: 'preview-1' })) + .toThrow(/exactly https:\/\/portfolio\.localhost\//i); + } + }); + it('rejects controls, non-NFC URLs, and ports at all public URL boundaries', () => { expect(() => parseSiteConfig({ environment: 'preview', canonicalUrl: 'https://portfolio.localhost/\n', buildId: 'preview-1', diff --git a/tests/unit/production-inputs.test.ts b/tests/unit/production-inputs.test.ts index e3a7b87..1b5689d 100644 --- a/tests/unit/production-inputs.test.ts +++ b/tests/unit/production-inputs.test.ts @@ -80,4 +80,17 @@ describe('production inputs', () => { expect(() => assetUrl(manifest, 'selected-build-one', base)).toThrow(/asset url base/i); } }); + + it('rejects raw C0 controls and DEL in an asset URL base before URL parsing', () => { + const manifest = parsePublicAssetManifest(JSON.parse(readFileSync(fixtureUrl('asset-manifest.valid.json'), 'utf8'))); + for (const base of [ + '/assets/\n../private/', + '/assets/\nprivate/', + '/assets/\tprivate/', + '/assets/\rprivate/', + '/assets/\u007fprivate/', + ]) { + expect(() => assetUrl(manifest, 'selected-build-one', base)).toThrow(/asset url base/i); + } + }); });