Skip to content

Allow trusted localhost image sources in server-side renders #779

Description

@Jeffrey903

Satori 0.29.0 added SSRF protection for server-side image fetching in #769. Thank you for addressing this security risk.

Unfortunately, the new protection unconditionally blocks localhost, with no supported way for an application to opt out, allow a trusted host, or provide its own fetch implementation. This prevents us from upgrading from 0.28.1.

Our application uses Satori to generate Open Graph images. In local development, application assets are served by our local development server:

http://localhost:5555/images/opengraph-background.png

After upgrading to 0.29.0, local rendering fails with:

Error: Image source resolves to a blocked address (SSRF protection):
http://localhost:5555/images/opengraph-background.png
    at assertSafeServerFetchUrl (...)
    at resolveImageData (...)

These are trusted, repository-owned static assets served by our local development environment. The same code uses a public HTTPS asset origin in production.

Impact

This breaks all local development and testing of the affected Open Graph image-rendering paths. Developers can no longer accurately test these images using the same local application and asset server used by the rest of the project.

Because there is no supported escape hatch, our current choices are to:

  • remain on Satori 0.28.1;
  • patch Satori locally;
  • rewrite our image-loading pipeline; or
  • configure local development to fetch assets from a remote deployment, which means developers are no longer testing their local assets.

We do not want to remove SSRF protection in production, and we would not enable any reduced-security behavior there. We only need an explicit way to support trusted private destinations in a controlled local environment.

Possible solutions

We would be happy with any API that preserves the secure default while allowing the embedding application to make an explicit, local-only decision.

1. Per-render private-network opt-out

For example:

await satori(element, {
  width,
  height,
  fonts,
  dangerouslyAllowPrivateNetwork: process.env.NODE_ENV !== "production",
});

The option should default to false, with documentation explaining that it must only be enabled when all image sources are trusted.

A narrower variation could permit loopback without permitting every private network:

allowLoopbackImageSources: process.env.NODE_ENV !== "production",

2. Per-image-request allow callback

A callback would let applications make a more targeted decision:

await satori(element, {
  width,
  height,
  fonts,
  allowUnsafeImageUrl(url) {
    return (
      process.env.NODE_ENV !== "production" &&
      new URL(url).hostname === "localhost"
    );
  },
});

Satori could continue applying its normal protection unless the callback explicitly permits the individual URL.

This could also be expressed as an allowlist:

allowedImageHosts:
  process.env.NODE_ENV !== "production" ? ["localhost"] : [],

A callback is more flexible, but either form would let us authorize only the local asset server rather than disabling the checker entirely.

3. Pluggable image fetcher

The initial version of #769 included a custom fetcher, but it was removed because a typeof fetch signature would allow caller-controlled Request and redirect behavior and would not compose safely with hardened fetch libraries.

A narrower signature could avoid that concern:

type ImageFetcher = (url: string) => Promise<Response>;

await satori(element, {
  width,
  height,
  fonts,
  fetcher: applicationSafeFetch,
});

Satori would provide only the URL string, leaving redirect policy and network enforcement entirely to the application-supplied implementation.

Our application already has a hardened server-side fetch implementation that:

  • blocks private, loopback, link-local, and reserved addresses in deployed environments;
  • validates resolved addresses at connection time;
  • applies the same protection to redirects; and
  • explicitly permits private destinations only in the local environment.

Allowing us to provide that implementation would both restore local development and provide stronger production protection than a literal-host check alone.

An image resolver returning data rather than a Response could be another option:

type ImageResolver = (url: string) => Promise<ArrayBuffer | string>;

Security expectations

We are not requesting that localhost be allowed by default.

Any escape hatch could:

  • retain the current secure behavior by default;
  • require an explicit, prominently named option;
  • document the SSRF consequences;
  • be configured independently for each Satori render; and
  • allow applications to keep the protection enabled in production.

In our case, reduced protection would only be enabled in local development. Production uses public HTTPS asset origins and would continue using SSRF enforcement or our application’s hardened fetcher.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions