gitroomhq/postiz-app · error · HttpException

Failed to fetch URL

Error message

Failed to fetch URL

What it means

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.

Source

Thrown at apps/backend/src/public-api/routes/v1/public.integrations.controller.ts:120

    );
  }

  @Post('/upload-from-url')
  async uploadsFromUrl(
    @GetOrgFromRequest() org: Organization,
    @Body() body: UploadDto
  ) {
    Sentry.metrics.count('public_api-request', 1);
    let response: globalThis.Response;
    try {
      response = await fetch(body.url, {
        // @ts-ignore — undici option, not in lib.dom fetch types
        dispatcher: ssrfSafeDispatcher,
      });
    } catch {
      // Network-level failure (DNS, connection refused, SSRF block, etc.) —
      // fetch rejects rather than returning a non-ok response.
      throw new HttpException({ msg: 'Failed to fetch URL' }, 400);
    }
    if (!response.ok) {
      throw new HttpException({ msg: 'Failed to fetch URL' }, 400);
    }

    // Guard against OOM: bail out before buffering the whole body into memory.
    // Content-Length may be absent or wrong, so we re-check the real size after
    // download too. The type isn't known yet (sniffed below), so the pre-check
    // uses the largest allowed cap (video).
    const maxDownloadSize = getMaxSize('video/mp4');
    const declaredSize = Number(response.headers.get('content-length'));
    if (declaredSize && declaredSize > maxDownloadSize) {
      throw new HttpException({ msg: 'File is too large.' }, 400);
    }

    const buffer = Buffer.from(await response.arrayBuffer());
    const detected = await fileTypeFromBuffer(buffer);
    if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) {

View on GitHub (pinned to 0f1647f749)

Solutions

  1. Verify the URL opens from the backend server itself (curl from inside the container/host), not just your laptop
  2. If testing locally, use a publicly reachable URL (or a tunnel like ngrok) instead of localhost/private IPs — the SSRF dispatcher will block them
  3. Check DNS and TLS: the target must have a valid certificate and resolvable hostname
  4. If the target must be an internal allowlisted host, configure the SSRF dispatcher's allowlist rather than bypassing it

Example fix

// before
await fetch('http://localhost:4000/media/photo.jpg');

// after
await fetch('https://my-public-bucket.example.com/media/photo.jpg');
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight from the caller's side (or a server you control)
const res = await fetch(mediaUrl, { method: 'HEAD' });
if (!res.ok) throw new Error(`Unreachable media URL: ${res.status}`);
// Ensure it's a public host, not localhost/private IPs
const host = new URL(mediaUrl).hostname;
if (/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(host)) {
  throw new Error('Private/internal hosts are SSRF-blocked by the API');
}

Type guard

const isPubliclyFetchableUrl = (u: string): boolean => {
  try {
    const { protocol, hostname } = new URL(u);
    if (protocol !== 'https:' && protocol !== 'http:') return false;
    return !/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.0\.0\.0)/.test(hostname);
  } catch { return false; }
};

Try / catch

try {
  await postiz.publicApi.uploadsFromUrl({ url });
} catch (e) {
  if (e?.response?.data?.msg === 'Failed to fetch URL' && networkLevel) {
    // fetch rejected: DNS/TLS/SSRF — host the media publicly and retry
  }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of gitroomhq/postiz-app@0f1647f749 (2026-08-27). Data as JSON: /api/errors/39dbe5ba5cbe32eb. Report an issue: GitHub.