{"record":{"id":"060a3f57e38ada3c","repo":"stablyai/orca","slug":"spritesheet-must-not-be-a-symlink","errorCode":null,"errorMessage":"spritesheet must not be a symlink.","messagePattern":"spritesheet must not be a symlink\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/main/ipc/pet.ts","lineNumber":305,"sourceCode":"      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    }\n    if (sheetStat.size > MAX_BYTES) {\n      throw new Error(\n        `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`","sourceCodeStart":287,"sourceCodeEnd":323,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/src/main/ipc/pet.ts#L287-L323","documentation":"Thrown at pet.ts:304-305 when isSymlink(sheetSrc) returns true after the path has been validated to stay inside the bundle. This closes a TOCTOU/symlink-escape hole: an attacker could place a relative, in-bundle path that is itself a symlink pointing outside the bundle (the prefix check at line 301 would pass because the path string is in-bundle, but the symlink target would not be).","triggerScenarios":"The spritesheet file (after resolve + prefix check) is a symbolic link — typically pointing to a file outside the bundle, but any symlink is rejected outright. isSymlink is awaited, so it inspects lstat.","commonSituations":"Malicious bundle with a symlinked sprite; a developer's local symlink to a shared assets folder; a bundle unzipped in a way that preserved symlinks from a tarball.","solutions":["Replace the symlink with a real copy of the image file inside the bundle directory.","Re-export the bundle with cp -L (dereference) so symlinks are flattened to real files."],"exampleFix":"# before — spritesheet.png is a symlink to /home/user/assets/cat.png\nln -s /home/user/assets/cat.png bundle/spritesheet.png\n# after — copy the real bytes\ncp /home/user/assets/cat.png bundle/spritesheet.png","handlingStrategy":"validation","validationCode":"import { lstat } from 'node:fs/promises'\nasync function assertNotSymlink(p: string) {\n  const st = await lstat(p)\n  if (st.isSymbolicLink()) throw new Error(`${p} is a symlink`)\n}","typeGuard":null,"tryCatchPattern":"try { await importPetBundle(p) }\ncatch (e) { if (e instanceof Error && e.message === 'spritesheet must not be a symlink.') { /* tell user to replace symlink with a copy */ } else throw e }","preventionTips":["When building bundles, copy image bytes (cp -L / rsync -L) rather than preserving symlinks.","Inspect the bundle with 'ls -la' to confirm no symlinks before import."],"tags":["pet-bundle","security","symlink","toctou"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}