From 0a34f90bb974506ae585c0963faf8daf32143181 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 00:40:09 +0300 Subject: [PATCH] fix: harden portfolio input boundary --- src/content/asset-url.ts | 24 +++++++++++--- src/content/schemas.ts | 44 ++++++++++++++++++------- tests/fixtures/site-config.preview.json | 2 +- tests/unit/content-schemas.test.ts | 41 +++++++++++++++++++++++ tests/unit/production-inputs.test.ts | 16 +++++++++ 5 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/content/asset-url.ts b/src/content/asset-url.ts index b2ce602..70916d1 100644 --- a/src/content/asset-url.ts +++ b/src/content/asset-url.ts @@ -1,12 +1,28 @@ import type { PublicAssetManifest } from './types.ts'; -export const assetUrl = (manifest: PublicAssetManifest, assetId: string, base = '/assets/'): string => { - if (!base.startsWith('/') || base.startsWith('//') || /[?#]/u.test(base)) { - throw new Error('Asset URL base must be an absolute path without a query or fragment.'); +const syntheticOrigin = 'https://portfolio-assets.invalid'; + +const normalizeAssetBase = (base: string): string => { + if ( + !base.startsWith('/') || + base.startsWith('//') || + /[\\%?#]/u.test(base) || + base.split('/').some((segment) => segment === '.' || segment === '..') + ) { + throw new Error('Asset URL base must be a safe absolute path.'); } + const resolved = new URL(base, syntheticOrigin); + if (resolved.origin !== syntheticOrigin) { + throw new Error('Asset URL base must remain under the synthetic origin.'); + } + const normalizedPath = resolved.pathname.replace(/\/+/gu, '/'); + return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; +}; + +export const assetUrl = (manifest: PublicAssetManifest, assetId: string, base = '/assets/'): string => { const asset = manifest.assets.find((candidate) => candidate.id === assetId); if (asset === undefined) { throw new Error(`Unknown asset ID: ${assetId}`); } - return `${base.endsWith('/') ? base : `${base}/`}${encodeURIComponent(asset.fileName)}`; + return `${normalizeAssetBase(base)}${encodeURIComponent(asset.fileName)}`; }; diff --git a/src/content/schemas.ts b/src/content/schemas.ts index 08c5168..9b3385c 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -10,14 +10,40 @@ const socialHosts = { LinkedIn: 'linkedin.com', } as const; +const hasUnpairedSurrogate = (value: string): boolean => { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +}; const hasVisibleCopy = (value: string): boolean => - value.trim().length > 0 && value === value.normalize('NFC') && !/[\u0000-\u001f\u007f]/u.test(value); -const visibleString = z.string().refine(hasVisibleCopy, 'Visible copy must be non-empty, NFC-normalized, and control-character free.'); + value.trim().length > 0 && + value === value.normalize('NFC') && + !hasUnpairedSurrogate(value) && + !/[\u0000-\u001f\u007f]/u.test(value); +const visibleString = z.string().refine(hasVisibleCopy, 'Visible copy must be non-empty, well-formed Unicode, NFC-normalized, and control-character free.'); const opaqueId = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, 'IDs must be generated safe identifiers.'); const httpUrl = z.string().refine((value) => { + if (value !== value.normalize('NFC') || hasUnpairedSurrogate(value) || /[\u0000-\u001f\u007f]/u.test(value)) { + return false; + } try { const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash; + const authority = /^https:\/\/([^/?#]+)/u.exec(value)?.[1]; + return url.protocol === 'https:' && + !url.username && + !url.password && + !url.search && + !url.hash && + authority !== undefined && + !authority.includes(':'); } catch { return false; } @@ -183,15 +209,11 @@ const siteConfigSchema = z.object({ 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 (url.pathname !== '/') { - context.addIssue({ code: 'custom', message: 'Canonical URL must not contain a path.', path: ['canonicalUrl'] }); + if (site.environment === 'preview' && (url.hostname !== 'portfolio.localhost' || url.port || url.pathname !== '/')) { + context.addIssue({ code: 'custom', message: 'Preview canonical URL must be exactly https://portfolio.localhost/.', path: ['canonicalUrl'] }); } - const previewHost = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname.endsWith('.test'); - if (site.environment === 'preview' && !previewHost) { - context.addIssue({ code: 'custom', message: 'Preview canonical URL must use a localhost or .test host.', path: ['canonicalUrl'] }); - } - if (site.environment === 'production' && previewHost) { - context.addIssue({ code: 'custom', message: 'Production canonical URL may not use a preview host.', path: ['canonicalUrl'] }); + if (site.environment === 'production') { + context.addIssue({ code: 'custom', message: 'Production hostname is not approved.', path: ['canonicalUrl'] }); } }); diff --git a/tests/fixtures/site-config.preview.json b/tests/fixtures/site-config.preview.json index 2f6e809..9cd519d 100644 --- a/tests/fixtures/site-config.preview.json +++ b/tests/fixtures/site-config.preview.json @@ -1,5 +1,5 @@ { "environment": "preview", - "canonicalUrl": "https://portfolio.example.test", + "canonicalUrl": "https://portfolio.localhost/", "buildId": "preview-1" } diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 2da26e2..2bbcf47 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -16,6 +16,8 @@ const validManifest = fixture('asset-manifest.valid.json'); const validSite = fixture('site-config.preview.json'); type MutableContentFixture = { navigation: Array<{ id: string }>; + profile: { introduction: string }; + contact: { social: Array<{ service: string; url: string }> }; projects: { selected: Array<{ visibility: string; url?: string; assetIds: string[]; title: string }>; }; @@ -46,6 +48,45 @@ describe('content schemas', () => { })).toThrow(/unrecognized/i); }); + it('accepts only the fixed preview canonical URL and no production hostname', () => { + expect(() => parseSiteConfig({ + environment: 'preview', canonicalUrl: 'https://portfolio.example.test/', buildId: 'preview-1', + })).toThrow(/portfolio\.localhost/i); + expect(() => parseSiteConfig({ + environment: 'preview', canonicalUrl: 'https://portfolio.localhost:444/', buildId: 'preview-1', + })).toThrow(/portfolio\.localhost/i); + expect(() => parseSiteConfig({ + environment: 'production', canonicalUrl: 'https://portfolio.localhost/', buildId: 'production-1', + })).toThrow(/production hostname is not approved/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', + })).toThrow(/url/i); + expect(() => parseSiteConfig({ + environment: 'preview', canonicalUrl: 'https://portfolio.localhost:443/', buildId: 'preview-1', + })).toThrow(/url/i); + + const content = cloneContent(); + content.contact.social[0]!.url = 'https://github.com:443/example'; + expect(() => parsePortfolioContent(content)).toThrow(/url/i); + + const project = cloneContent(); + project.projects.selected[1]!.url = 'https://example.test/cafe\u0301'; + expect(() => parsePortfolioContent(project)).toThrow(/url/i); + }); + + it('rejects visible copy containing an unpaired UTF-16 surrogate', () => { + const content = cloneContent(); + content.profile.introduction = 'Broken \ud800 copy'; + expect(() => parsePortfolioContent(content)).toThrow(/visible copy/i); + + const wellFormedContent = cloneContent(); + wellFormedContent.profile.introduction = 'Well-formed 🚗 copy'; + expect(() => parsePortfolioContent(wellFormedContent)).not.toThrow(); + }); + it('rejects an asset reference absent from the manifest', () => { const content = cloneContent(); content.projects.selected[0]!.assetIds = ['missing-asset']; diff --git a/tests/unit/production-inputs.test.ts b/tests/unit/production-inputs.test.ts index f351312..e3a7b87 100644 --- a/tests/unit/production-inputs.test.ts +++ b/tests/unit/production-inputs.test.ts @@ -62,6 +62,22 @@ describe('production inputs', () => { it('resolves only an exact manifest asset ID to an encoded deterministic URL', () => { const manifest = parsePublicAssetManifest(JSON.parse(readFileSync(fixtureUrl('asset-manifest.valid.json'), 'utf8'))); expect(assetUrl(manifest, 'selected-build-one')).toBe('/assets/selected-build-one.webp'); + expect(assetUrl(manifest, 'selected-build-one', '/assets/cards')).toBe('/assets/cards/selected-build-one.webp'); expect(() => assetUrl(manifest, 'missing')).toThrow(/unknown asset/i); }); + + it('rejects asset URL base traversal and origin escapes', () => { + const manifest = parsePublicAssetManifest(JSON.parse(readFileSync(fixtureUrl('asset-manifest.valid.json'), 'utf8'))); + for (const base of [ + '//cdn.example.test/assets/', + '/assets/../private/', + '/assets\\private/', + '/assets/%2e%2e/private/', + '/assets/%2fprivate/', + '/assets/?query=1', + '/assets/#fragment', + ]) { + expect(() => assetUrl(manifest, 'selected-build-one', base)).toThrow(/asset url base/i); + } + }); });