{"record":{"id":"39dbe5ba5cbe32eb","repo":"gitroomhq/postiz-app","slug":"failed-to-fetch-url","errorCode":null,"errorMessage":"Failed to fetch URL","messagePattern":"Failed to fetch URL","errorType":"http","errorClass":"HttpException","httpStatus":400,"severity":"error","filePath":"apps/backend/src/public-api/routes/v1/public.integrations.controller.ts","lineNumber":120,"sourceCode":"    );\n  }\n\n  @Post('/upload-from-url')\n  async uploadsFromUrl(\n    @GetOrgFromRequest() org: Organization,\n    @Body() body: UploadDto\n  ) {\n    Sentry.metrics.count('public_api-request', 1);\n    let response: globalThis.Response;\n    try {\n      response = await fetch(body.url, {\n        // @ts-ignore — undici option, not in lib.dom fetch types\n        dispatcher: ssrfSafeDispatcher,\n      });\n    } catch {\n      // Network-level failure (DNS, connection refused, SSRF block, etc.) —\n      // fetch rejects rather than returning a non-ok response.\n      throw new HttpException({ msg: 'Failed to fetch URL' }, 400);\n    }\n    if (!response.ok) {\n      throw new HttpException({ msg: 'Failed to fetch URL' }, 400);\n    }\n\n    // Guard against OOM: bail out before buffering the whole body into memory.\n    // Content-Length may be absent or wrong, so we re-check the real size after\n    // download too. The type isn't known yet (sniffed below), so the pre-check\n    // uses the largest allowed cap (video).\n    const maxDownloadSize = getMaxSize('video/mp4');\n    const declaredSize = Number(response.headers.get('content-length'));\n    if (declaredSize && declaredSize > maxDownloadSize) {\n      throw new HttpException({ msg: 'File is too large.' }, 400);\n    }\n\n    const buffer = Buffer.from(await response.arrayBuffer());\n    const detected = await fileTypeFromBuffer(buffer);\n    if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) {","sourceCodeStart":102,"sourceCodeEnd":138,"githubUrl":"https://github.com/gitroomhq/postiz-app/blob/0f1647f7491a217d43eb5ae7a480484bdf0aff3e/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts#L102-L138","documentation":"The public API's uploadsFromUrl endpoint wraps fetch() in a try/catch; any network-level rejection (DNS failure, connection refused, TLS error, or the SSRF-safe dispatcher blocking a private/internal IP) is converted into a 400 HttpException with msg 'Failed to fetch URL'. It means the server could not even establish a response, as opposed to receiving an HTTP error status.","triggerScenarios":"POST /public/v1/uploads/from-url with a URL that doesn't resolve, points at a stopped server, uses a self-signed/bad certificate, or targets a loopback/private address (127.0.0.1, 10.x, 169.254.x, localhost) which the ssrfSafeDispatcher intentionally blocks. Also firewalled egress or proxy misconfiguration on the backend host.","commonSituations":"Developers testing with http://localhost:3000/file.png or an internal staging host, which the SSRF guard blocks; typo'd domains; DNS not resolving inside Docker/K8s; outbound requests blocked by network policy.","solutions":["Verify the URL opens from the backend server itself (curl from inside the container/host), not just your laptop","If testing locally, use a publicly reachable URL (or a tunnel like ngrok) instead of localhost/private IPs — the SSRF dispatcher will block them","Check DNS and TLS: the target must have a valid certificate and resolvable hostname","If the target must be an internal allowlisted host, configure the SSRF dispatcher's allowlist rather than bypassing it"],"exampleFix":"// before\nawait fetch('http://localhost:4000/media/photo.jpg');\n\n// after\nawait fetch('https://my-public-bucket.example.com/media/photo.jpg');","handlingStrategy":"validation","validationCode":"// Pre-flight from the caller's side (or a server you control)\nconst res = await fetch(mediaUrl, { method: 'HEAD' });\nif (!res.ok) throw new Error(`Unreachable media URL: ${res.status}`);\n// Ensure it's a public host, not localhost/private IPs\nconst host = new URL(mediaUrl).hostname;\nif (/^(localhost|127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|169\\.254\\.)/.test(host)) {\n  throw new Error('Private/internal hosts are SSRF-blocked by the API');\n}","typeGuard":"const isPubliclyFetchableUrl = (u: string): boolean => {\n  try {\n    const { protocol, hostname } = new URL(u);\n    if (protocol !== 'https:' && protocol !== 'http:') return false;\n    return !/^(localhost|127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|169\\.254\\.|0\\.0\\.0\\.0)/.test(hostname);\n  } catch { return false; }\n};","tryCatchPattern":"try {\n  await postiz.publicApi.uploadsFromUrl({ url });\n} catch (e) {\n  if (e?.response?.data?.msg === 'Failed to fetch URL' && networkLevel) {\n    // fetch rejected: DNS/TLS/SSRF — host the media publicly and retry\n  }\n}","preventionTips":["Always serve media from public, TLS-valid URLs","Never point at localhost or internal IPs — the SSRF dispatcher blocks them","Run a HEAD pre-flight before submitting the URL"],"tags":["network","ssrf","fetch","upload","public-api"],"backgroundTag":"fetch-network-error","analyzedSha":"0f1647f7491a217d43eb5ae7a480484bdf0aff3e","analyzedAt":"2026-08-27T12:09:55.020Z","schemaVersion":2},"datasetVersion":"2026-08-27T13:17:12.746Z"}