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