fix: harden portfolio input boundary

This commit is contained in:
2026-09-02 00:40:09 +03:00
parent 4a704d89f7
commit 0a34f90bb9
5 changed files with 111 additions and 16 deletions

View File

@@ -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)}`;
};

View File

@@ -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'] });
}
});