Skip to navigation

Security

What zriz handles about hostile files and private photos, and what stays your job.

What zriz does on its own

zriz sends nothing anywhere. The photo stays in the browser until your code uploads something. The browser decodes it in a worker, and an export draws pixels onto a canvas and encodes them, so only the crop's pixels reach the file.

We ran files carrying each of these blocks through exportCrop in Chromium, Firefox and WebKit, through the server crop route, and through plain sharp, then parsed the results:

In the upload`exportCrop`Server crop routesharp with `keepMetadata()`
GPS and EXIFGoneGoneKept
EXIF thumbnail of the whole photoGoneGoneKept
XMP and IPTCGoneGoneKept
Pixels outside the cropGoneGoneGone
A second image, video or other bytes after the imageGoneGoneGone
Gain mapGoneGoneGone, and keepGainMap() keeps a cropped one

Safari writes a small EXIF block of its own into a JPEG or PNG, with only the crop's size and colour space.

Treat the original as private

Cropping hides part of a photo, and the original still holds all of it. The file a person picks carries their location, the time, the camera, a thumbnail of the uncropped frame, and on many phones a short video. Someone who crops a face or a street sign out expects it to be gone.

What to do, best first:

ChoiceWhat you do
Upload the exportSend the blob from exportCrop and never the file. The original stays on the device
Cut on the server and drop the originalUse server crop, store the answer and let the upload go when the request ends. Don't write it to disk, to a queue or to logs
Keep a copy to crop again laterStore the copy below in a private bucket, never at a public URL and never beside the crop, and tell the person you keep the whole frame. Delete it when they delete the crop

cleanCopy decodes the upload and encodes it again, which drops every block in the table. It keeps every pixel and fills clear ones white, as the route does, so it is still the whole photo:

import sharp from "sharp";

export const cleanCopy = async (input: Buffer) =>
  await sharp(input, { limitInputPixels: 120_000_000 })
    .rotate()
    .flatten({ background: "#ffffff" })
    .jpeg({ quality: 92 })
    .toBuffer();

On a pipeline opened from an upload, never call keepMetadata(), keepExif(), withMetadata(), withExif() or withExifMerge(). Each one kept the EXIF thumbnail in our test, so a crop can carry a small picture of the frame it cut away. To write your own tags, cut the photo to raw pixels first, as the route does, and add them to a new pipeline.

The crop value and details.pixelCrop tell where the crop sits and how large the original is. Keep them out of public URLs when that matters.

Apple and phone formats

FileWhat happens
HEIC and HEIFOnly Safari decodes them. Other browsers fail with unsupported-format, and prebuilt sharp has no HEVC decoder, so the route answers 415. A decode function, as in loading images, sends the original to your server, so the rules above apply to it
Live photoThe still and the video are two files. zriz gets the file you pass and never sees the other. If your own picker uploads both, you upload the video too
Motion photoThe video sits after the image in one file. Exports and the route drop it, and the original keeps it
Gain map, MPFA second image in the file for HDR screens, covering the whole frame. Exports and the route drop it, and the exported photo is SDR
P3 and other ICC profilesThe colour is converted on decode. An export is sRGB unless you ask for display-p3, and the only profile in the file is the browser's own

Decoder bugs

zriz has no image decoder of its own. In the browser the decoding is done by the browser and the operating system, and a page cannot patch them or see their version. Bugs there are real. CVE-2023-4863 in libwebp was exploited through Chrome before 116.0.5845.187, and CVE-2023-41064 in Apple's ImageIO was used against iPhones with no click. Opening a file in a cropper is the same exposure as showing it in an img. Tell people to keep their browser and system updated.

On your server, sharp is your decoder and you own its patches. Use 0.35.5 or newer, which bundles libvips 8.18.7. Versions before 0.35.0 are open to GHSA-f88m-g3jw-g9cj. Run npm audit in CI, and turn off the decoders you don't need with sharp.block(), as the route does. Don't point sharp at a libvips from your system, because that one brings its own loaders and its own patch level.

The server crop route answers 413 for a photo over 120 megapixels. MAX_PIXELS is the one constant to raise. A photo at that limit peaked at about 2 GB in our test, so raise it only with memory to match.

zriz reads the pixel count before it decodes and refuses a file over maxPixels. Lower it if your page only needs small photos, as in loading images. It sets no limit on bytes, so check file.size before you pass a file.

SVG

zriz draws an SVG through an img from a data URL, so the browser runs no script in it and fetches nothing it points at. We tried scripts, event attributes, foreignObject, external images, stylesheets, fonts and use in all three engines, with no script run and no request sent. The export is a bitmap, so nothing from the SVG source survives in it.

Drawing an SVG can still freeze the page. Firefox and Safari draw it on the main thread. A file under 1 kB that repeats one shape through nested use grows to a million shapes and froze all three engines for minutes in our test, and timeout cannot stop a drawing that has started.

So zriz reads the file as XML before it draws and counts what the drawing would hold, through every use, marker and SVG image inside it. The load fails with too-large when the file would draw 50,000 shapes or more, holds 100,000 path points or more, 500,000 characters of text or more, applies a filter to 1,000 shapes or more, chains 40 filter steps or more, uses a convolution matrix of order 7 or more, nests use 12 levels deep, refers to itself, or is over 10 MB. The check takes about 10 ms on a 600 kB file. Of 53 real icons, logos, illustrations and maps we tried, none came close, and the largest held 17,916 path points.

Nested patterns, masks, clip paths, blur and huge sizes need no check, because zriz draws an SVG at most at the preview size. No count can prove that every SVG draws fast, so when people upload photos and you have no use for SVG, pass svg={false} to Root.

The danger is your own code using the source. A blob URL of a file runs with your origin when it opens as a document, so a script inside the SVG acts as your site. An img is safe. These are not:

Never do this with an uploaded file
dangerouslySetInnerHTML or innerHTML with the text of the file
object, embed or iframe with its URL
A link or window.open to its blob: URL

Serve what people upload

The file name, the extension and the mime type come from the client, and a file can be a valid image and valid HTML at once. zriz reads the format from the bytes, and so does sharp, but your upload route decides how a file comes back to a browser.

For every file you serve back:

RuleWhy
Encode it again and serve that, as cleanCopy doesExtra bytes in the file don't survive
Set content-type from what you encoded, never from the uploadA declared text/html or image/svg+xml would run
Send x-content-type-options: nosniffThe browser won't guess a type from the bytes
Send content-security-policy: default-src 'none'; sandboxIf a file does open as a document, it runs nothing
Serve from a separate domain, such as yourappusercontent.comA file that slips through can't reach your cookies or your storage
Name the stored file with a random idA name from the client can hold ../ or overwrite another file
export const serveUpload = (jpeg: Buffer) =>
  new Response(new Uint8Array(jpeg), {
    headers: {
      "content-type": "image/jpeg",
      "content-disposition": 'inline; filename="photo.jpg"',
      "content-security-policy": "default-src 'none'; sandbox",
      "x-content-type-options": "nosniff",
    },
  });

Set a content security policy

These sources let the cropper work with its worker. Merge them into your own policy. Loading images says what each one enables, and what falls back when it is missing.

export const cropperPolicy = [
  "img-src 'self' blob: data:",
  "worker-src blob:",
  "connect-src 'self'",
].join("; ");

zriz injects no style tag and calls no innerHTML or eval, so script-src needs nothing for it, and style-src needs nothing for the parts' own styles. A style prop of yours on a server rendered part stays refused, and the recipes size Viewport, Dial and Zoom and paint ZoomThumb that way. Add style-src-attr 'unsafe-inline' to allow it, or drop the style prop and give those parts a class from your stylesheet. The same goes for a style attribute in your own HTML, as in the vanilla JS markup. A <style> element in your HTML is refused too, and style-src-attr does not cover it. Move its rules to your stylesheet, or list its hash in style-src. With trusted types, add zriz to trusted-types.

URLs you pass as src

zriz fetches a string src, and the fetch sends cookies to your own origin. Never pass a URL a visitor typed, because it makes other visitors' browsers call any path on your site, and a host of the visitor's choice learns the IP address and page origin of everyone who opens it. Check the host against your own list first.

Pass crossOrigin="use-credentials" only for hosts you trust. To hide your page from the host, set referrerpolicy for the page with <meta name="referrer" content="no-referrer">.

Don't add a URL field to a server route. Fetching a URL from a client lets them reach your internal network. The server crop route takes a file for that reason.

A ZrizError message for an error status holds the URL as you passed it, cut at 80 characters, without the query or fragment. A CORS or network failure holds no URL. If a secret sits in your path, don't log the message.

Protect the server route

The server crop route refuses values it cannot read, oversize bodies and unused decoders. It keeps any other value inside the photo, so a forged one can only move the crop. It can't know who is calling, so connect canCrop to your session check, add a rate limit per user, and set a body limit at your proxy too.

A saved value is client input as well. parse returns a clean object or null for anything in localStorage or a database, and a cropper keeps a value inside the photo, so a tampered one can only move the crop.

Report a problem

Report it privately at [GitHub](https://github.com/pqoqubbw/zriz/security/advisories/new), not in a public issue.