fix: harden portfolio input boundary
This commit is contained in:
@@ -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