gitroomhq/postiz-app · error · Error
Unsafe URL
Error message
Unsafe URL
What it means
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.
Source
Thrown at libraries/nestjs-libraries/src/upload/local.storage.ts:34
'image/tiff',
'video/mp4',
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
]);
export class LocalStorage implements IUploadProvider {
constructor(private uploadDirectory: string) {}
async uploadSimple(path: string) {
const dataUrl = path.startsWith('data:') ? parseDataUrl(path) : null;
let body: Buffer;
if (dataUrl) {
body = dataUrl.buffer;
} else {
if (!(await isSafePublicHttpsUrl(path))) {
throw new Error('Unsafe URL');
}
const loadImage = await fetch(path, {
// @ts-ignore — undici option, not in lib.dom fetch types
dispatcher: ssrfSafeDispatcher,
});
body = Buffer.from(await loadImage.arrayBuffer());
}
// Never trust the claimed mime/extension (data URL header, remote
// content-type, or URL path): sniff the real type from the bytes and
// only accept the allow-list, otherwise an attacker could write an
// arbitrary file (e.g. .html/.svg with embedded script) into the
// publicly served uploads directory on the app's own origin.
const detected = await fileTypeFromBuffer(body);
if (!detected || !LOCAL_STORAGE_ALLOWED_MIME.has(detected.mime)) {
throw new Error('Unsupported file type.');
}
const findExtension = detected.ext;View on GitHub (pinned to 0f1647f749)
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
Example fix
// before
await localStorage.uploadSimple(undefined, 'http://localhost:5173/avatar.png');
// after
await localStorage.uploadSimple(
{ buffer: fs.readFileSync('./avatar.png') } as any, // dataUrl path, no fetch
undefined
); Defensive patterns
Strategy: validation
Validate before calling
import dns from 'node:dns/promises';
async function isSafeUrl(u: string): Promise<boolean> {
try { const url = new URL(u);
if (url.protocol !== 'https:') return false;
const addrs = await dns.resolve(url.hostname).catch(() => []);
return addrs.length > 0 && addrs.every(a => !isPrivateIp(a));
} catch { return false; }
} Type guard
const isHttpsUrl = (u: string): boolean => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; Try / catch
try { await uploadSimple(undefined, url); } catch (e) { if ((e as Error).message === 'Unsafe URL') useDataUrlFallback(); else throw e; } Prevention
- Only pass public https:// media URLs
- Use the dataUrl/buffer path for local assets
- Never point remote fetches at internal hostnames
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Unsafe URL
- Failed to fetch URL
- File is too large.
- Unsupported file type.
- All media must be uploaded through our upload API route and
AI-assisted analysis of gitroomhq/postiz-app@0f1647f749 (2026-08-27).
Data as JSON: /api/errors/9991746270a18952.
Report an issue: GitHub.