{"record":{"id":"9991746270a18952","repo":"gitroomhq/postiz-app","slug":"unsafe-url-999174","errorCode":null,"errorMessage":"Unsafe URL","messagePattern":"Unsafe URL","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"libraries/nestjs-libraries/src/upload/local.storage.ts","lineNumber":34,"sourceCode":"  'image/tiff',\n  'video/mp4',\n  'audio/mpeg',\n  'audio/mp4',\n  'audio/wav',\n  'audio/ogg',\n]);\nexport class LocalStorage implements IUploadProvider {\n  constructor(private uploadDirectory: string) {}\n\n  async uploadSimple(path: string) {\n    const dataUrl = path.startsWith('data:') ? parseDataUrl(path) : null;\n\n    let body: Buffer;\n    if (dataUrl) {\n      body = dataUrl.buffer;\n    } else {\n      if (!(await isSafePublicHttpsUrl(path))) {\n        throw new Error('Unsafe URL');\n      }\n      const loadImage = await fetch(path, {\n        // @ts-ignore — undici option, not in lib.dom fetch types\n        dispatcher: ssrfSafeDispatcher,\n      });\n      body = Buffer.from(await loadImage.arrayBuffer());\n    }\n\n    // Never trust the claimed mime/extension (data URL header, remote\n    // content-type, or URL path): sniff the real type from the bytes and\n    // only accept the allow-list, otherwise an attacker could write an\n    // arbitrary file (e.g. .html/.svg with embedded script) into the\n    // publicly served uploads directory on the app's own origin.\n    const detected = await fileTypeFromBuffer(body);\n    if (!detected || !LOCAL_STORAGE_ALLOWED_MIME.has(detected.mime)) {\n      throw new Error('Unsupported file type.');\n    }\n    const findExtension = detected.ext;","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/gitroomhq/postiz-app/blob/0f1647f7491a217d43eb5ae7a480484bdf0aff3e/libraries/nestjs-libraries/src/upload/local.storage.ts#L16-L52","documentation":"uploadSimple accepts either a data URL or a remote URL; for remote URLs it runs isSafePublicHttpsUrl before fetching. If the URL fails that SSRF check (not public HTTPS, resolves to a private/loopback/link-local IP, or points at the server's own network), it throws 'Unsafe URL'. The fetch itself uses an ssrfSafeDispatcher, so this is a layered SSRF defense.","triggerScenarios":"Passing an http:// (non-HTTPS) URL, a hostname that resolves to 10.x/127.x/169.254.x/::1, a CDN that CNAMEs into private space, localhost aliases, or an unreachable/misresolving domain. DNS rebinding or multi-A-record hosts where one record is private also fail.","commonSituations":"Local development pointing at http://localhost:3000 or a LAN IP; staging environments behind private DNS; using http:// preview URLs from image CDNs; corporate DNS resolving external names to internal IPs.","solutions":["Use a publicly reachable https:// URL for the image","For local dev, upload via data URL (dataUrl path) or configure a public tunnel (ngrok/etc.) with HTTPS","If the host legitimately resolves to both public and private IPs, fix DNS or host the asset on a clean public CDN","Inspect isSafePublicHttpsUrl's resolution logic and replicate its checks in a script to debug the failing hostname"],"exampleFix":"// before\nawait localStorage.uploadSimple(undefined, 'http://localhost:5173/avatar.png');\n\n// after\nawait localStorage.uploadSimple(\n  { buffer: fs.readFileSync('./avatar.png') } as any, // dataUrl path, no fetch\n  undefined\n);","handlingStrategy":"validation","validationCode":"import dns from 'node:dns/promises';\nasync function isSafeUrl(u: string): Promise<boolean> {\n  try { const url = new URL(u);\n    if (url.protocol !== 'https:') return false;\n    const addrs = await dns.resolve(url.hostname).catch(() => []);\n    return addrs.length > 0 && addrs.every(a => !isPrivateIp(a));\n  } catch { return false; }\n}","typeGuard":"const isHttpsUrl = (u: string): boolean => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };","tryCatchPattern":"try { await uploadSimple(undefined, url); } catch (e) { if ((e as Error).message === 'Unsafe URL') useDataUrlFallback(); else throw e; }","preventionTips":["Only pass public https:// media URLs","Use the dataUrl/buffer path for local assets","Never point remote fetches at internal hostnames"],"tags":["ssrf","upload","url-validation","security"],"backgroundTag":"ssrf-url-blocked","analyzedSha":"0f1647f7491a217d43eb5ae7a480484bdf0aff3e","analyzedAt":"2026-08-27T12:09:55.020Z","schemaVersion":2},"datasetVersion":"2026-08-27T13:17:12.746Z"}