fix: harden portfolio input boundary
This commit is contained in:
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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'] });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
2
tests/fixtures/site-config.preview.json
vendored
2
tests/fixtures/site-config.preview.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"environment": "preview",
|
||||
"canonicalUrl": "https://portfolio.example.test",
|
||||
"canonicalUrl": "https://portfolio.localhost/",
|
||||
"buildId": "preview-1"
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user