test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal, behaviour-preserving extraction in file.utils.ts. All suites green: server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86. integrations (server): - file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from extractZipInternal so the zip-slip/path-traversal guard is unit-testable; call site rerouted, behaviour identical (only a warn-message string merged). - file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion cases (mutation-resistant: fails if containment loses the path.sep). - import-formatter / import.utils / table-utils / export utils / import.service extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki formatting, table colspan widths (idempotent), slug/link rewriting. client: - safeRedirectPath: open-redirect guard, every reject branch independently. - buildChatMarkdown (fence anti-breakout), label-colors, normalize-label, share tree build, page URL builders, notification time-grouping (fake clock). packages: - editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted values, orphan footnote extraction. - mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder. Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,52 @@ export function getFileTaskFolderPath(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure path-safety decision for a single ZIP entry (zip-slip / path-traversal guard).
|
||||
*
|
||||
* Reproduces exactly the inline check previously embedded in `extractZipInternal`:
|
||||
* 1. Strip any leading slashes from the entry name.
|
||||
* 2. Reject names that fail `yauzl.validateFileName` (e.g. backslashes,
|
||||
* relative `..` segments, drive letters).
|
||||
* 3. Reject `__MACOSX/` metadata entries.
|
||||
* 4. Resolve the entry against the target directory and require it to stay
|
||||
* strictly inside `targetDir` using a `targetResolved + path.sep` prefix check
|
||||
* (the trailing separator prevents sibling-directory prefix confusion, e.g.
|
||||
* `/tmp/x` must not match `/tmp/x-evil`).
|
||||
*
|
||||
* @param entryName The decoded (UTF-8) entry file name from the archive.
|
||||
* @param targetDir Directory the archive is being extracted into.
|
||||
* @returns `{ safe }` and, when safe, the resolved absolute path of the entry.
|
||||
*/
|
||||
export function isEntryPathSafe(
|
||||
entryName: string,
|
||||
targetDir: string,
|
||||
): { safe: boolean; resolved?: string } {
|
||||
// Strip leading slashes so absolute-looking entries cannot escape the target.
|
||||
const safe = entryName.replace(/^\/+/, '');
|
||||
|
||||
const validationError = yauzl.validateFileName(safe);
|
||||
if (validationError) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
// Skip macOS resource-fork metadata entries.
|
||||
if (safe.startsWith('__MACOSX/')) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
const fullPath = path.join(targetDir, safe);
|
||||
const resolved = path.resolve(fullPath);
|
||||
const targetResolved = path.resolve(targetDir);
|
||||
|
||||
// Containment check: resolved path must live strictly inside the target dir.
|
||||
if (!resolved.startsWith(targetResolved + path.sep)) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
return { safe: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a ZIP archive.
|
||||
*/
|
||||
@@ -103,29 +149,15 @@ function extractZipInternal(
|
||||
const name = entry.fileName.toString('utf8');
|
||||
const safe = name.replace(/^\/+/, '');
|
||||
|
||||
const validationError = yauzl.validateFileName(safe);
|
||||
if (validationError) {
|
||||
console.warn(`Skipping invalid entry (${validationError})`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (safe.startsWith('__MACOSX/')) {
|
||||
// Zip-slip / path-traversal guard (see isEntryPathSafe).
|
||||
if (!isEntryPathSafe(name, target).safe) {
|
||||
console.warn(`Skipping unsafe entry: ${safe}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(target, safe);
|
||||
|
||||
const resolved = path.resolve(fullPath);
|
||||
const targetResolved = path.resolve(target);
|
||||
|
||||
if (!resolved.startsWith(targetResolved + path.sep)) {
|
||||
console.warn(`Skipping entry (path outside target): ${safe}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle directories
|
||||
if (/\/$/.test(name)) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user