Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/src/contracts/api-contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"mimeTypes": [
"application/pdf",
"image/png",
"image/jpeg"
"image/jpeg",
"image/svg+xml"
]
}
}
4 changes: 3 additions & 1 deletion backend/src/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ const fileFilter: multer.Options['fileFilter'] = (_req, file, callback) => {
}

return callback(
new BadRequestException('Only PDF, PNG, or JPEG files are allowed'),
new BadRequestException(
'Only PDF, PNG, JPEG, or SVG files are allowed',
),
);
};

Expand Down
140 changes: 140 additions & 0 deletions backend/src/documents/pipes/file-validation.pipe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ async function createValidPng(): Promise<Buffer> {
.toBuffer();
}

function createSvg(markup: string): Buffer {
return Buffer.from(markup, 'utf8');
}

describe('FileValidationPipe', () => {
let pipe: FileValidationPipe;

Expand Down Expand Up @@ -90,6 +94,142 @@ describe('FileValidationPipe', () => {
expect(result.mimetype).toBe('image/jpeg');
});

it('should accept static SVG content and store a bounded PNG', async () => {
const buffer = createSvg(`
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<title>Parcel A &amp; B</title>
<defs>
<linearGradient id="paint">
<stop offset="0" stop-color="#fff" />
</linearGradient>
</defs>
<path id="shape" d="M0 0 L120 40" fill="url(#paint)" stroke="#123456" stroke-width="2" />
<text aria-label="https://maps.example/Parcel"><textPath href="#shape">Parcel A</textPath></text>
</svg>
`);
const file = createFile(buffer, 'image/svg+xml', 'survey.svg');

const result = await pipe.transform(file);

expect(result.mimetype).toBe('image/png');
expect(result.buffer.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(result.size).toBe(result.buffer.length);
});

it.each<[string, RegExp]>([
['<script>alert(1)</script>', /active or resource-bearing content/i],
['<script />', /active or resource-bearing content/i],
['<animate attributeName="href" values="javascript:alert(1)" />', /active or resource-bearing content/i],
['<style>rect { fill: red }</style>', /active or resource-bearing content/i],
['<foreignObject><div>html</div></foreignObject>', /active or resource-bearing content/i],
['<image href="data:image/png;base64,AA==" />', /active or resource-bearing content/i],
['<feImage href="https://attacker.example/pixel.png" />', /active or resource-bearing content/i],
])('should reject active SVG element %s', async (element, expectedError) => {
const buffer = createSvg(
`<svg xmlns="http://www.w3.org/2000/svg">${element}</svg>`,
);
const file = createFile(buffer, 'image/svg+xml', 'active.svg');

await expect(pipe.transform(file)).rejects.toThrow(expectedError);
});

it.each([
'<rect width="1" height="1" onload="alert(1)" />',
'<rect width="1" height="1" style="fill:url(https://attacker.example/paint)" />',
])('should reject active SVG attribute in %s', async (element) => {
const buffer = createSvg(
`<svg xmlns="http://www.w3.org/2000/svg">${element}</svg>`,
);
const file = createFile(buffer, 'image/svg+xml', 'active-attribute.svg');

await expect(pipe.transform(file)).rejects.toThrow(
/active or unsafe attributes/i,
);
});

it('should preserve inert descriptive attributes that contain URL-like text', async () => {
const buffer = createSvg(
'<svg xmlns="http://www.w3.org/2000/svg"><text aria-label="url(https://maps.example/reference)" data-reference="url(https://maps.example/data)">Parcel</text></svg>',
);

const result = await pipe.transform(
createFile(buffer, 'image/svg+xml', 'descriptive.svg'),
);

expect(result.mimetype).toBe('image/png');
});

it.each([
'<rect fill="url(/**/https://attacker.example/paint)" />',
'<rect fill="u\\72l(https://attacker.example/paint)" />',
])('should reject obfuscated external CSS URLs in %s', async (element) => {
const buffer = createSvg(
`<svg xmlns="http://www.w3.org/2000/svg">${element}</svg>`,
);

await expect(
pipe.transform(
createFile(buffer, 'image/svg+xml', 'external-css.svg'),
),
).rejects.toThrow(/external or embedded resource/i);
});

it('should reject unsupported elements and oversized tags before rasterization', async () => {
const unsupported = createSvg(
'<svg xmlns="http://www.w3.org/2000/svg"><unknown /></svg>',
);
const oversized = createSvg(
`<svg xmlns="http://www.w3.org/2000/svg"><rect data-note="${'a'.repeat(64 * 1024)}" /></svg>`,
);

await expect(
pipe.transform(
createFile(unsupported, 'image/svg+xml', 'unknown.svg'),
),
).rejects.toThrow(/not supported/i);
await expect(
pipe.transform(
createFile(oversized, 'image/svg+xml', 'oversized.svg'),
),
).rejects.toThrow(BadRequestException);
});

it('should reject external SVG references', async () => {
const buffer = createSvg(
'<svg xmlns="http://www.w3.org/2000/svg">' +
'<text><textPath href="https://attacker.example/path">Parcel</textPath></text>' +
'</svg>',
);
const file = createFile(buffer, 'image/svg+xml', 'external.svg');

await expect(pipe.transform(file)).rejects.toThrow(
/resource-bearing|external|embedded resource/i,
);
});

it('should reject data URLs in SVG attributes', async () => {
const buffer = createSvg(
'<svg xmlns="http://www.w3.org/2000/svg">' +
'<text><textPath href="data:image/png;base64,AA==">Parcel</textPath></text>' +
'</svg>',
);
const file = createFile(buffer, 'image/svg+xml', 'data.svg');

await expect(pipe.transform(file)).rejects.toThrow(
/resource-bearing|external|embedded resource/i,
);
});

it('should reject SVG entities and external resources', async () => {
const buffer = createSvg(`
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>
`);
const file = createFile(buffer, 'image/svg+xml', 'entity.svg');

await expect(pipe.transform(file)).rejects.toThrow(/unsafe XML content/i);
});

it('should reject an executable renamed to .pdf', async () => {
const buffer = Buffer.from('MZ\x90\x00');
const file = createFile(buffer, 'application/pdf', 'malware.pdf');
Expand Down
Loading
Loading