update
This commit is contained in:
480
test/suite/einvoice_security/test.sec-09.safe-errors.ts
Normal file
480
test/suite/einvoice_security/test.sec-09.safe-errors.ts
Normal file
@ -0,0 +1,480 @@
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EInvoice } from '../../../ts/index.js';
|
||||
import { PerformanceTracker } from '../performance.tracker.js';
|
||||
import * as path from 'path';
|
||||
|
||||
const performanceTracker = new PerformanceTracker('SEC-09: Safe Error Messages');
|
||||
|
||||
tap.test('SEC-09: Safe Error Messages - should provide secure error messages without leaking sensitive information', async (t) => {
|
||||
const einvoice = new EInvoice();
|
||||
|
||||
// Test 1: File path disclosure prevention
|
||||
const filePathDisclosure = await performanceTracker.measureAsync(
|
||||
'file-path-disclosure-prevention',
|
||||
async () => {
|
||||
const sensitiveFiles = [
|
||||
'/home/user/invoices/secret/invoice.xml',
|
||||
'C:\\Users\\Admin\\Documents\\Confidential\\invoice.pdf',
|
||||
'/var/www/private/customer-data.xml',
|
||||
'../../../../../../etc/passwd'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const filePath of sensitiveFiles) {
|
||||
try {
|
||||
// Attempt to read non-existent file
|
||||
await einvoice.readFile(filePath);
|
||||
} catch (error) {
|
||||
const errorMsg = error.message || error.toString();
|
||||
|
||||
results.push({
|
||||
originalPath: filePath,
|
||||
errorMessage: errorMsg,
|
||||
leaksPath: errorMsg.includes(filePath) ||
|
||||
errorMsg.includes('/home/') ||
|
||||
errorMsg.includes('C:\\') ||
|
||||
errorMsg.includes('/var/'),
|
||||
leaksUsername: errorMsg.includes('user') ||
|
||||
errorMsg.includes('Admin'),
|
||||
leaksSystemInfo: errorMsg.includes('Linux') ||
|
||||
errorMsg.includes('Windows') ||
|
||||
errorMsg.includes('Darwin')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
filePathDisclosure.forEach(result => {
|
||||
t.notOk(result.leaksPath, 'Error does not leak file path');
|
||||
t.notOk(result.leaksUsername, 'Error does not leak username');
|
||||
t.notOk(result.leaksSystemInfo, 'Error does not leak system info');
|
||||
});
|
||||
|
||||
// Test 2: Database error message sanitization
|
||||
const databaseErrorSanitization = await performanceTracker.measureAsync(
|
||||
'database-error-sanitization',
|
||||
async () => {
|
||||
const dbErrors = [
|
||||
{
|
||||
type: 'connection',
|
||||
original: 'Connection failed to database server at 192.168.1.100:5432 with user "admin"',
|
||||
expected: 'Database connection failed'
|
||||
},
|
||||
{
|
||||
type: 'query',
|
||||
original: 'ERROR: relation "invoices" does not exist at character 15',
|
||||
expected: 'Database query failed'
|
||||
},
|
||||
{
|
||||
type: 'auth',
|
||||
original: 'FATAL: password authentication failed for user "invoice_user"',
|
||||
expected: 'Database authentication failed'
|
||||
},
|
||||
{
|
||||
type: 'schema',
|
||||
original: 'ERROR: column "credit_card_number" of relation "customers" does not exist',
|
||||
expected: 'Database operation failed'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const dbError of dbErrors) {
|
||||
try {
|
||||
// Simulate database operation that would throw error
|
||||
const sanitized = await einvoice.sanitizeDatabaseError(dbError.original);
|
||||
|
||||
results.push({
|
||||
type: dbError.type,
|
||||
sanitized: sanitized,
|
||||
leaksIP: sanitized.includes('192.168') || sanitized.includes(':5432'),
|
||||
leaksSchema: sanitized.includes('invoices') || sanitized.includes('customers'),
|
||||
leaksCredentials: sanitized.includes('admin') || sanitized.includes('invoice_user'),
|
||||
leaksColumns: sanitized.includes('credit_card_number')
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: dbError.type,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
databaseErrorSanitization.forEach(result => {
|
||||
t.notOk(result.leaksIP, `${result.type}: Does not leak IP addresses`);
|
||||
t.notOk(result.leaksSchema, `${result.type}: Does not leak schema names`);
|
||||
t.notOk(result.leaksCredentials, `${result.type}: Does not leak credentials`);
|
||||
t.notOk(result.leaksColumns, `${result.type}: Does not leak column names`);
|
||||
});
|
||||
|
||||
// Test 3: XML parsing error sanitization
|
||||
const xmlParsingErrorSanitization = await performanceTracker.measureAsync(
|
||||
'xml-parsing-error-sanitization',
|
||||
async () => {
|
||||
const xmlErrors = [
|
||||
{
|
||||
xml: '<Invoice><Amount>not-a-number</Amount></Invoice>',
|
||||
errorType: 'validation'
|
||||
},
|
||||
{
|
||||
xml: '<Invoice><CreditCard>4111111111111111</CreditCard></Invoice>',
|
||||
errorType: 'sensitive-data'
|
||||
},
|
||||
{
|
||||
xml: '<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><Invoice>&xxe;</Invoice>',
|
||||
errorType: 'xxe-attempt'
|
||||
},
|
||||
{
|
||||
xml: '<Invoice xmlns:hack="javascript:alert(1)"><hack:script/></Invoice>',
|
||||
errorType: 'xss-attempt'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of xmlErrors) {
|
||||
try {
|
||||
await einvoice.parseXML(test.xml);
|
||||
} catch (error) {
|
||||
const errorMsg = error.message;
|
||||
|
||||
results.push({
|
||||
errorType: test.errorType,
|
||||
errorMessage: errorMsg,
|
||||
leaksSensitiveData: errorMsg.includes('4111111111111111'),
|
||||
leaksSystemPaths: errorMsg.includes('/etc/passwd') || errorMsg.includes('file:///'),
|
||||
leaksAttackVector: errorMsg.includes('javascript:') || errorMsg.includes('<!ENTITY'),
|
||||
providesHint: errorMsg.includes('XXE') || errorMsg.includes('external entity')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
xmlParsingErrorSanitization.forEach(result => {
|
||||
t.notOk(result.leaksSensitiveData, `${result.errorType}: Does not leak sensitive data`);
|
||||
t.notOk(result.leaksSystemPaths, `${result.errorType}: Does not leak system paths`);
|
||||
t.notOk(result.leaksAttackVector, `${result.errorType}: Does not leak attack details`);
|
||||
});
|
||||
|
||||
// Test 4: Stack trace sanitization
|
||||
const stackTraceSanitization = await performanceTracker.measureAsync(
|
||||
'stack-trace-sanitization',
|
||||
async () => {
|
||||
const operations = [
|
||||
{ type: 'parse-error', fn: () => einvoice.parseXML('<invalid>') },
|
||||
{ type: 'validation-error', fn: () => einvoice.validate({}) },
|
||||
{ type: 'conversion-error', fn: () => einvoice.convert(null, 'ubl') },
|
||||
{ type: 'file-error', fn: () => einvoice.readFile('/nonexistent') }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
await op.fn();
|
||||
} catch (error) {
|
||||
const fullError = error.stack || error.toString();
|
||||
const userError = await einvoice.getUserFriendlyError(error);
|
||||
|
||||
results.push({
|
||||
type: op.type,
|
||||
originalHasStack: fullError.includes('at '),
|
||||
userErrorHasStack: userError.includes('at '),
|
||||
leaksInternalPaths: userError.includes('/src/') ||
|
||||
userError.includes('/node_modules/') ||
|
||||
userError.includes('\\src\\'),
|
||||
leaksFunctionNames: userError.includes('parseXML') ||
|
||||
userError.includes('validateSchema') ||
|
||||
userError.includes('convertFormat'),
|
||||
leaksLineNumbers: /:\d+:\d+/.test(userError)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
stackTraceSanitization.forEach(result => {
|
||||
t.notOk(result.userErrorHasStack, `${result.type}: User error has no stack trace`);
|
||||
t.notOk(result.leaksInternalPaths, `${result.type}: Does not leak internal paths`);
|
||||
t.notOk(result.leaksFunctionNames, `${result.type}: Does not leak function names`);
|
||||
t.notOk(result.leaksLineNumbers, `${result.type}: Does not leak line numbers`);
|
||||
});
|
||||
|
||||
// Test 5: API key and credential scrubbing
|
||||
const credentialScrubbing = await performanceTracker.measureAsync(
|
||||
'credential-scrubbing',
|
||||
async () => {
|
||||
const errorScenarios = [
|
||||
{
|
||||
error: 'API call failed with key: sk_live_abc123def456',
|
||||
type: 'api-key'
|
||||
},
|
||||
{
|
||||
error: 'Authentication failed for Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
type: 'jwt-token'
|
||||
},
|
||||
{
|
||||
error: 'Database connection string: mongodb://user:password123@localhost:27017/db',
|
||||
type: 'connection-string'
|
||||
},
|
||||
{
|
||||
error: 'AWS credentials invalid: AKIAIOSFODNN7EXAMPLE',
|
||||
type: 'aws-key'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scenario of errorScenarios) {
|
||||
const scrubbed = await einvoice.scrubSensitiveData(scenario.error);
|
||||
|
||||
results.push({
|
||||
type: scenario.type,
|
||||
original: scenario.error,
|
||||
scrubbed: scrubbed,
|
||||
containsKey: scrubbed.includes('sk_live_') || scrubbed.includes('AKIA'),
|
||||
containsPassword: scrubbed.includes('password123'),
|
||||
containsToken: scrubbed.includes('eyJ'),
|
||||
properlyMasked: scrubbed.includes('***') || scrubbed.includes('[REDACTED]')
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
credentialScrubbing.forEach(result => {
|
||||
t.notOk(result.containsKey, `${result.type}: API keys are scrubbed`);
|
||||
t.notOk(result.containsPassword, `${result.type}: Passwords are scrubbed`);
|
||||
t.notOk(result.containsToken, `${result.type}: Tokens are scrubbed`);
|
||||
t.ok(result.properlyMasked, `${result.type}: Sensitive data is properly masked`);
|
||||
});
|
||||
|
||||
// Test 6: Version and framework disclosure
|
||||
const versionDisclosure = await performanceTracker.measureAsync(
|
||||
'version-framework-disclosure',
|
||||
async () => {
|
||||
const errors = [];
|
||||
|
||||
// Collect various error messages
|
||||
const operations = [
|
||||
() => einvoice.parseXML('<invalid>'),
|
||||
() => einvoice.validateFormat('unknown'),
|
||||
() => einvoice.convertFormat({}, 'invalid'),
|
||||
() => einvoice.readFile('/nonexistent')
|
||||
];
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
await op();
|
||||
} catch (error) {
|
||||
errors.push(error.message || error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const results = {
|
||||
errors: errors.length,
|
||||
leaksNodeVersion: errors.some(e => e.includes('v14.') || e.includes('v16.') || e.includes('v18.')),
|
||||
leaksFramework: errors.some(e => e.includes('Express') || e.includes('Fastify') || e.includes('NestJS')),
|
||||
leaksLibraryVersion: errors.some(e => e.includes('@fin.cx/einvoice@') || e.includes('version')),
|
||||
leaksXMLParser: errors.some(e => e.includes('libxml') || e.includes('sax') || e.includes('xmldom')),
|
||||
leaksOS: errors.some(e => e.includes('Linux') || e.includes('Darwin') || e.includes('Windows NT'))
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
t.notOk(versionDisclosure.leaksNodeVersion, 'Does not leak Node.js version');
|
||||
t.notOk(versionDisclosure.leaksFramework, 'Does not leak framework information');
|
||||
t.notOk(versionDisclosure.leaksLibraryVersion, 'Does not leak library version');
|
||||
t.notOk(versionDisclosure.leaksXMLParser, 'Does not leak XML parser details');
|
||||
t.notOk(versionDisclosure.leaksOS, 'Does not leak operating system');
|
||||
|
||||
// Test 7: Timing attack prevention in errors
|
||||
const timingAttackPrevention = await performanceTracker.measureAsync(
|
||||
'timing-attack-prevention',
|
||||
async () => {
|
||||
const validationTests = [
|
||||
{ id: 'VALID-001', valid: true },
|
||||
{ id: 'INVALID-AT-START', valid: false },
|
||||
{ id: 'INVALID-AT-END-OF-VERY-LONG-ID', valid: false }
|
||||
];
|
||||
|
||||
const timings = [];
|
||||
|
||||
for (const test of validationTests) {
|
||||
const iterations = 100;
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
await einvoice.validateInvoiceId(test.id);
|
||||
} catch (error) {
|
||||
// Expected for invalid IDs
|
||||
}
|
||||
|
||||
const end = process.hrtime.bigint();
|
||||
times.push(Number(end - start) / 1000000); // Convert to ms
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
const variance = times.reduce((sum, time) => sum + Math.pow(time - avgTime, 2), 0) / times.length;
|
||||
|
||||
timings.push({
|
||||
id: test.id,
|
||||
valid: test.valid,
|
||||
avgTime,
|
||||
variance,
|
||||
stdDev: Math.sqrt(variance)
|
||||
});
|
||||
}
|
||||
|
||||
// Check if timing differences are significant
|
||||
const validTiming = timings.find(t => t.valid);
|
||||
const invalidTimings = timings.filter(t => !t.valid);
|
||||
|
||||
const timingDifferences = invalidTimings.map(t => ({
|
||||
id: t.id,
|
||||
difference: Math.abs(t.avgTime - validTiming.avgTime),
|
||||
significantDifference: Math.abs(t.avgTime - validTiming.avgTime) > validTiming.stdDev * 3
|
||||
}));
|
||||
|
||||
return {
|
||||
timings,
|
||||
differences: timingDifferences,
|
||||
constantTime: !timingDifferences.some(d => d.significantDifference)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(timingAttackPrevention.constantTime, 'Error responses have constant timing');
|
||||
|
||||
// Test 8: Error aggregation and rate limiting info
|
||||
const errorAggregation = await performanceTracker.measureAsync(
|
||||
'error-aggregation-rate-limiting',
|
||||
async () => {
|
||||
const results = {
|
||||
individualErrors: [],
|
||||
aggregatedError: null,
|
||||
leaksPatterns: false
|
||||
};
|
||||
|
||||
// Generate multiple errors
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
await einvoice.parseXML(`<Invalid${i}>`);
|
||||
} catch (error) {
|
||||
results.individualErrors.push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if errors reveal patterns
|
||||
const uniqueErrors = new Set(results.individualErrors);
|
||||
results.leaksPatterns = uniqueErrors.size > 5; // Too many unique errors might reveal internals
|
||||
|
||||
// Test aggregated error response
|
||||
try {
|
||||
await einvoice.batchProcess([
|
||||
'<Invalid1>',
|
||||
'<Invalid2>',
|
||||
'<Invalid3>'
|
||||
]);
|
||||
} catch (error) {
|
||||
results.aggregatedError = error.message;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
t.notOk(errorAggregation.leaksPatterns, 'Errors do not reveal internal patterns');
|
||||
t.ok(errorAggregation.aggregatedError, 'Batch operations provide aggregated errors');
|
||||
|
||||
// Test 9: Internationalization of error messages
|
||||
const errorInternationalization = await performanceTracker.measureAsync(
|
||||
'error-internationalization',
|
||||
async () => {
|
||||
const locales = ['en', 'de', 'fr', 'es', 'it'];
|
||||
const results = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
try {
|
||||
await einvoice.parseXML('<Invalid>', { locale });
|
||||
} catch (error) {
|
||||
const errorMsg = error.message;
|
||||
|
||||
results.push({
|
||||
locale,
|
||||
message: errorMsg,
|
||||
isLocalized: !errorMsg.includes('Invalid XML'), // Should not be raw English
|
||||
containsTechnicalTerms: /XML|parser|schema|validation/i.test(errorMsg),
|
||||
userFriendly: !/:|\bat\b|\.js|\\|\//.test(errorMsg) // No technical indicators
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
errorInternationalization.forEach(result => {
|
||||
t.ok(result.userFriendly, `${result.locale}: Error message is user-friendly`);
|
||||
});
|
||||
|
||||
// Test 10: Error logging vs user display
|
||||
const errorLoggingVsDisplay = await performanceTracker.measureAsync(
|
||||
'error-logging-vs-display',
|
||||
async () => {
|
||||
let loggedError = null;
|
||||
let displayedError = null;
|
||||
|
||||
// Mock logger to capture logged error
|
||||
const originalLog = console.error;
|
||||
console.error = (error) => { loggedError = error; };
|
||||
|
||||
try {
|
||||
await einvoice.parseXML('<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><x>&xxe;</x>');
|
||||
} catch (error) {
|
||||
displayedError = error.message;
|
||||
}
|
||||
|
||||
console.error = originalLog;
|
||||
|
||||
return {
|
||||
loggedError: loggedError?.toString() || '',
|
||||
displayedError: displayedError || '',
|
||||
logContainsDetails: loggedError?.includes('XXE') || loggedError?.includes('entity'),
|
||||
displayIsGeneric: !displayedError.includes('XXE') && !displayedError.includes('/etc/passwd'),
|
||||
logHasStackTrace: loggedError?.includes('at '),
|
||||
displayHasStackTrace: displayedError.includes('at ')
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
t.ok(errorLoggingVsDisplay.logContainsDetails, 'Logged error contains technical details');
|
||||
t.ok(errorLoggingVsDisplay.displayIsGeneric, 'Displayed error is generic and safe');
|
||||
t.notOk(errorLoggingVsDisplay.displayHasStackTrace, 'Displayed error has no stack trace');
|
||||
|
||||
// Print performance summary
|
||||
performanceTracker.printSummary();
|
||||
});
|
||||
|
||||
// Run the test
|
||||
tap.start();
|
Reference in New Issue
Block a user