# Security

> [llms.txt](https://zriz.dev/llms.txt)

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](https://zriz.dev/docs/server-crop.md) route, and through plain sharp, then parsed the results:

| In the upload | \`exportCrop\` | Server crop route | sharp with \`keepMetadata()\` |
| - | - | - | - |
| GPS and EXIF | Gone | Gone | Kept |
| EXIF thumbnail of the whole photo | Gone | Gone | Kept |
| XMP and IPTC | Gone | Gone | Kept |
| Pixels outside the crop | Gone | Gone | Gone |
| A second image, video or other bytes after the image | Gone | Gone | Gone |
| Gain map | Gone | Gone | Gone, 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:

| Choice | What you do |
| - | - |
| Upload the export | Send the blob from `exportCrop` and never the file. The original stays on the device |
| Cut on the server and drop the original | Use [server crop](https://zriz.dev/docs/server-crop.md), 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 later | Store 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:

```ts
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

| File | What happens |
| - | - |
| HEIC and HEIF | Only 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](https://zriz.dev/docs/loading-images.md), sends the original to your server, so the rules above apply to it |
| Live photo | The 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 photo | The video sits after the image in one file. Exports and the route drop it, and the original keeps it |
| Gain map, MPF | A 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 profiles | The 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](https://zriz.dev/docs/server-crop.md) 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](https://zriz.dev/docs/loading-images.md). 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:

| Rule | Why |
| - | - |
| Encode it again and serve that, as `cleanCopy` does | Extra bytes in the file don't survive |
| Set `content-type` from what you encoded, never from the upload | A declared `text/html` or `image/svg+xml` would run |
| Send `x-content-type-options: nosniff` | The browser won't guess a type from the bytes |
| Send `content-security-policy: default-src 'none'; sandbox` | If a file does open as a document, it runs nothing |
| Serve from a separate domain, such as `yourappusercontent.com` | A file that slips through can't reach your cookies or your storage |
| Name the stored file with a random id | A name from the client can hold `../` or overwrite another file |

```ts
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](https://zriz.dev/docs/loading-images.md) says what each one enables, and what falls back when it is missing.

```ts
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](https://zriz.dev/docs/vanilla-js.md) 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](https://zriz.dev/docs/server-crop.md) 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](https://zriz.dev/docs/server-crop.md) 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.
