Files
odysseus/tests/e2e/teardown.js
T
Alexandre Teixeira 6d102a213f fix(security): confine teardown paths and avoid interpolating exception data in logs
- routes/gallery/gallery_routes.py: use stable diagnostic log message without
  interpolating raw exception, resolver details, tokens, or destination strings.
- tests/e2e/teardown.js: remove environment-variable path overrides from production
  code, strictly confine test options and temp directories, and prevent arbitrary
  directory cleanup.
- Add regression tests covering non-leakage of synthetic tokens/queries/exceptions
  into logs and responses, and path confinement during teardown.
2026-10-28 21:14:43 +00:00

132 lines
5.2 KiB
JavaScript

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const repoRoot = path.resolve(__dirname, '..', '..');
const defaultResultsDir = path.join(repoRoot, 'test-results');
const defaultReportDir = path.join(repoRoot, 'playwright-report');
const defaultSanitizerScript = path.join(repoRoot, 'scripts', 'sanitize_trace_artifacts.py');
/**
* Confine dynamic paths to explicitly allowed test directories.
*/
function validateTestPath(targetPath, allowedBases, label) {
if (!targetPath || typeof targetPath !== 'string') return null;
const resolved = path.resolve(targetPath);
const isAllowed = allowedBases.some((base) => {
const resolvedBase = path.resolve(base);
return resolved.startsWith(resolvedBase + path.sep) || resolved === resolvedBase;
});
if (!isAllowed) {
throw new Error(`Security rejection: ${label} path "${targetPath}" is outside allowed test directories`);
}
return resolved;
}
module.exports = async (options = {}) => {
// If options is a Playwright FullConfig object, ignore it and use safe defaults
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
// Strictly require it to be within an OS temporary directory and match the expected test prefix
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (dataDirectory && typeof dataDirectory === 'string') {
try {
const resolvedData = path.resolve(dataDirectory);
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map((b) => path.resolve(b));
const isTmp = allowedTmpBases.some((base) => resolvedData.startsWith(base + path.sep));
const baseName = path.basename(resolvedData);
if (isTmp && baseName.startsWith('odysseus-photo-editor-e2e-')) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
} catch (_) {}
}
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
// When traces are not opted-in, purge all archives unconditionally.
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
const allowedBases = [defaultResultsDir, defaultReportDir, os.tmpdir(), '/var/tmp', '/tmp'];
const resultsDir = testOpts.resultsDir
? validateTestPath(testOpts.resultsDir, allowedBases, 'resultsDir')
: defaultResultsDir;
const reportDir = testOpts.reportDir
? validateTestPath(testOpts.reportDir, allowedBases, 'reportDir')
: defaultReportDir;
if (fs.existsSync(reportDir)) {
try {
fs.rmSync(reportDir, { recursive: true, force: true });
} catch (_) {}
}
if (fs.existsSync(resultsDir)) {
const purgeSensitiveArtifacts = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeSensitiveArtifacts(fullPath);
} else if (entry.isFile() && (
entry.name.endsWith('.zip') ||
entry.name.endsWith('.tmp') ||
entry.name.endsWith('.png') ||
entry.name.endsWith('.har')
)) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const purgeZipAndTmp = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeZipAndTmp(fullPath);
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
const sanitizerScript = testOpts.sanitizerScript
? validateTestPath(testOpts.sanitizerScript, [path.join(repoRoot, 'scripts'), os.tmpdir(), '/var/tmp', '/tmp'], 'sanitizerScript')
: defaultSanitizerScript;
const rawTimeout = Number(testOpts.timeout) || 30_000;
const timeout = Math.max(100, Math.min(rawTimeout, 120_000));
let sanitizerSucceeded = false;
try {
execFileSync(python, [sanitizerScript, resultsDir], {
stdio: 'ignore',
timeout,
shell: false,
});
sanitizerSucceeded = true;
} catch (_) {
sanitizerSucceeded = false;
}
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
purgeZipAndTmp(resultsDir);
}
}
}
};