{"record":{"id":"f51399922d53b9a0","repo":"stablyai/orca","slug":"spritesheetpath-escapes-the-bundle","errorCode":null,"errorMessage":"spritesheetPath escapes the bundle.","messagePattern":"spritesheetPath escapes the bundle\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/main/ipc/pet.ts","lineNumber":302,"sourceCode":"    const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\\\/]+/g, sep)\n    if (\n      isAbsolute(manifest.spritesheetPath) ||\n      isAbsolute(normalizedSpritePath) ||\n      /^[a-zA-Z]:/.test(manifest.spritesheetPath)\n    ) {\n      throw new Error('spritesheetPath must be relative to the bundle.')\n    }\n    // Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving.\n    const sheetSrc = resolve(bundleDir, normalizedSpritePath)\n    const bundleResolved = resolve(bundleDir)\n    if (sheetSrc === bundleResolved) {\n      throw new Error('spritesheetPath must point to a file, not the bundle root.')\n    }\n    const bundleRoot = bundleResolved + sep\n    // Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check.\n    const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s\n    if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {\n      throw new Error('spritesheetPath escapes the bundle.')\n    }\n    if (await isSymlink(sheetSrc)) {\n      throw new Error('spritesheet must not be a symlink.')\n    }\n    const sheetClass = classifyFile(sheetSrc)\n    if (!sheetClass || sheetClass.ext === '.svg') {\n      // SVG can't be used as a sprite sheet (no pixel grid).\n      throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')\n    }\n    let sheetStat: Awaited<ReturnType<typeof stat>>\n    try {\n      sheetStat = await stat(sheetSrc)\n    } catch {\n      throw new Error('Spritesheet file not found.')\n    }\n    if (!sheetStat.isFile()) {\n      throw new Error('Spritesheet path is not a file.')\n    }","sourceCodeStart":284,"sourceCodeEnd":320,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/src/main/ipc/pet.ts#L284-L320","documentation":"Thrown at pet.ts:301-302 when the resolved spritesheet path does not start with the bundle root (bundleResolved + sep). This is the primary path-traversal/escape guard: even though Zod rejects a literal '..' at pet.ts:88, the resolve+prefix check is defense-in-depth against encoded separators, mixed separators, and other tricks that bypass the string check. The comparison is case-insensitive on Windows (pet.ts:300) because NTFS volumes are case-insensitive.","triggerScenarios":"spritesheetPath uses '../../etc/passwd' (if it slipped past the Zod refine), a value with mixed forward/back slashes that resolves outside the bundle, or on Windows a same-volume path that differs only in case from the bundle root to bypass a naive prefix check.","commonSituations":"Deliberately malicious bundle; a bundle author who used a symlinked relative path that points outside; cross-platform separator confusion after copying a bundle between Windows and POSIX.","solutions":["Make spritesheetPath a plain relative filename inside the bundle directory.","Remove any '..' segments or absolute components.","Re-export the bundle from a clean directory containing only the manifest and the spritesheet."],"exampleFix":"// before\n{ \"spritesheetPath\": \"../../../../etc/passwd\" }\n// after\n{ \"spritesheetPath\": \"spritesheet.png\" }","handlingStrategy":"validation","validationCode":"import { resolve, sep } from 'node:path'\nfunction assertSheetInsideBundle(bundleDir: string, spritePath: string) {\n  const sheetSrc = resolve(bundleDir, spritePath.replace(/[\\\\/]+/g, sep))\n  const bundleRoot = resolve(bundleDir) + sep\n  const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s\n  if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {\n    throw new Error('spritesheetPath escapes the bundle')\n  }\n}","typeGuard":"function staysInBundle(spritePath: string): boolean {\n  return !spritePath.includes('..') && !spritePath.startsWith('/') && !spritePath.startsWith('\\\\')\n}","tryCatchPattern":"try { await importPetBundle(p) }\ncatch (e) { if (e instanceof Error && e.message === 'spritesheetPath escapes the bundle.') { /* reject bundle as unsafe */ } else throw e }","preventionTips":["Treat this error as a possible malicious bundle; do not silently retry.","Keep spritesheetPath a single relative filename with no parent-directory segments."],"tags":["pet-bundle","security","path-traversal","validation"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}