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
- 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
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
- 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
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
- File is too large.
- Unsupported file type.
- All media must be uploaded through our upload API route and
- Unsafe URL
- Unsafe URL
AI-assisted analysis of gitroomhq/postiz-app@0f1647f749 (2026-08-27).
Data as JSON: /api/errors/39dbe5ba5cbe32eb.
Report an issue: GitHub.