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

  1. Use a publicly reachable https:// URL for the image
  2. For local dev, upload via data URL (dataUrl path) or configure a public tunnel (ngrok/etc.) with HTTPS
  3. If the host legitimately resolves to both public and private IPs, fix DNS or host the asset on a clean public CDN
  4. 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

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


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