# Security

- **Status:** current on 2026-11-03.
- **Related documents:** [Build a full app](https://surface.solenovis.com/docs/full-app.md), [SurfWave reference](https://surface.solenovis.com/docs/routes.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md), [ADR-0109](https://git.solenovis.com/henning.liemant/surface/src/branch/main/docs/adr/0109-the-limits-of-the-server.md)

This page lists the limits of the server of `surf build --target server`, their settings, and what an app must do itself.

---

## 1. Settings

The server reads these environment variables when it starts. A value that is not valid stops the start.

| Variable | Default | Meaning |
|---|---|---|
| `HOST` | `127.0.0.1` | The address of the server. With the default, only this machine reaches the server, for example a proxy on the same machine. A container needs `0.0.0.0`, and the `Dockerfile` of `--adapter docker` and `--adapter fly` sets it (ADR-0109). |
| `PORT` | `3000` | The port. |
| `ORIGIN` | none | The address of the site in the browser, for example `https://example.com`. A site behind a proxy needs it. See section 3. |
| `API_ORIGIN` | none | The `http` address of the API, for a relative `Http` request and for `/api/`, for example `http://api` or `http://127.0.0.1:8080`. With no port, the server uses port 80. A `/` at the end is optional. A path, or `https`, is not valid. |
| `BODY_SIZE_LIMIT` | `10M` | The bytes of the body of one request. A number can end with `K`, `M` or `G`. A larger body gets status 413. All bodies in memory at one time have 16 times this limit, and a request above that gets status 503. |
| `API_SIZE_LIMIT` | `64M` | The bytes of one answer of the API. A larger answer is an error of the `Http` call, or status 502 for `/api/`. |
| `MAX_CONNECTIONS` | `1024` | The open connections. A new connection above this limit gets status 503. The limit of open files of the system must be higher. |
| `REQUEST_TIMEOUT` | `30` | The seconds that a client has to send one request, and the seconds of one write. A slower client gets status 408. |
| `SURF_DEV` | none | `surf dev` sets it. The server then marks the answer of each page with the header `x-surf-page`, so `surf dev` serves its own page for a page, and the answer of an endpoint as it is. Do not set it in a deployment. |

---

## 2. Fixed limits

| Limit | Value | Result above the limit |
|---|---|---|
| The head of a request | 64 KiB | Status 431 |
| The headers of a request | 100 | Status 431 |
| The wait of a connection for its next request | 5 seconds | The connection closes |
| The wait of a page for its data | 10 seconds | The page shows what it has |
| A connection, a read and a write to the API | 30 seconds each | An error of the `Http` call |
| A picture on a native target | 2^26 pixels | The picture does not paint |

The server also refuses these requests, and it closes the connection after the answer:

| Request | Status |
|---|---|
| A path with no first `/`, a full address, or a `#` | 400 |
| A part `.` or `..` in a path, also in escapes | 400 |
| A `/` or a `\` in a part of a path: `%2F`, `%5C` or `\`, in each case of the letters | 400 |
| Two `content-length` values, or a value that is not digits | 400 |
| A space before the colon of a header, a line that does not end with CR and LF, or a control character in a header | 400 |
| HTTP/1.1 with no `Host`, or with two | 400 |
| `transfer-encoding` | 501 |
| Another version than HTTP/1.0 and HTTP/1.1 | 505 |

Each response has `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. A hook `after` can set another value. The server sends no `Content-Security-Policy`, because a policy depends on the app. A page with a `later` value has an inline script.

---

## 3. Run the server behind a proxy

The binary has no TLS. A public site needs a proxy in front of it. The proxy must do these tasks:

1. Do TLS, and send `Strict-Transport-Security`.
2. Limit the rate and the connections of each client. The server sees only the address of the proxy.
3. Send a request body with a length. The server refuses a body in chunks.

Set `ORIGIN` to the address that the browser shows. The server compares it with the `origin` of each form and each remote call. With no `ORIGIN`, the server compares the `origin` with `Host`, and a proxy that changes `Host` makes the server refuse each form of the site.

`ORIGIN` also decides `Secure` on a cookie: `https` adds it, and `http` leaves it out. With no `ORIGIN`, each cookie has `Secure`, except on `localhost`, `*.localhost`, `127.0.0.1` and `[::1]`. A browser drops such a cookie on plain HTTP. So a site on plain HTTP in a private network needs `ORIGIN=http://...`.

---

## 4. What an app must do itself

### Guard each way to the data

- `request.path` in a hook has one form: `/%61dmin/` reaches the hook as `/admin`, after a redirect. So compare it with `==`. For a folder, compare the folder and the text under it: `request.path == "/admin" || request.path.startsWith("/admin/")`.
- A path with `%2F` or `%5C` gets status 400. A rest parameter `[...rest]` joins its parts with `/`, so `/files/a%2Fb` and `/files/a/b` gave the page the same value. The guard of a folder saw only the second path. A route parameter therefore has no `/`. Put such a value into the query.
- A guard in the hook `before` also holds for the data of a page, because the hook reads the path of the page.
- A guard in a `layout` function holds for the page and for the data of the page. For a data request, a `load` function runs after each outer `layout` function, and the first failure stops it.
- A page render can start a `load` function with no `parent` together with the `layout` functions. The page does not show after a failure of a guard, but that `load` function can run. So a `load` function must not change data: use an action.
- A remote function has the path `/__remote/<file>/<name>`, not the path of a page. Check the session in the function, or in the hook.
- An endpoint of `server.surfs` and the forward of `/api/` have their own paths. Guard these paths too. An API must check its own requests, because a client can reach it through `/api/`.
- A page with no server `load` function has no data on the server. A link in the browser shows it with no request. Its code is in the script of the page, so do not put a secret into a page.

### Keep sessions on the server

- Store a random session id in a cookie, not a name. The server of `examples/notes` trusts a name, so that the guide stays short.
- A cookie of `Wave.setCookie` has `HttpOnly` and `SameSite=Lax`. `HttpOnly` protects the read of the cookie, not the calls of a script in the page.
- The server checks that a form comes from this site. An action must still check that the user can do the action.

### Treat each value of a user as hostile

- Check a redirect to a value of a user. The server refuses a line end, but `https://evil.example` and `//evil.example` are valid addresses. Accept only a path of your site:

  ```surf
  fn safePath(url: String) -> String {
      return url.startsWith("/") && !url.startsWith("//") && !url.contains("\\") ? url : "/";
  }
  ```

- Check a value before it goes into a URL of `Http`. The server refuses a control character and a step `..`, and a route parameter has no `/`. But `?` and `#` in a route parameter, and `/` in a value of the query, change the address. Use a matcher such as `[id=int]` or `[id=uuid]`, or a matcher of the project.
- Do not give an address from a user to `Http` on the server. The server can reach hosts that the user cannot reach.
- A relative URL of `Http` goes to `API_ORIGIN` with the cookies and the `authorization` header of the client. An absolute URL goes out with neither.
- The server has no `https:` client. An absolute URL must use `http:`, so use it only inside your own network.
- `href` and `action` take a relative URL, `http`, `https`, `mailto` and `tel`. The element gets no attribute for another scheme.

### Keep secrets out of the page

- The value of each `load` function reaches the browser, also a value of `load.server.surfs`. Return only what the page shows.
- `Wave.env` in a file that the browser also reads needs a name in quotes that starts with `PUBLIC_`. Read a secret in a file of the server: `*.server.surfs`, `server.surfs`, `actions.surfs`, `*.remote.surfs`, or a file in a `server` folder.
- Do not commit a key. `.gitignore` of `surf new` names `.env`, `*.pem` and `*.key`.

---

## 5. Tools

- `surf dev` listens on `127.0.0.1`. It answers only a `Host` of `127.0.0.1`, `localhost` or `[::1]` with its port. It serves only the files in `src/assets`.
- `surf dev` and the inspector refuse a request of a page of another origin with status 403. A browser sends no `Origin` for `<script src>`, so the server reads `Sec-Fetch-Site`: only `same-origin` and `none` pass. A page on `localhost` with another port is `same-site`, so it does not pass. A link of another site can open a page of the app in a window, for example the redirect of a sign-in service. That site cannot read the window. A frame of another site cannot open a page. The inspector opens no page for a link of another site.
- Each response of `surf dev` and of the inspector has `Cross-Origin-Resource-Policy: same-origin`. A browser with no `Sec-Fetch-Site` then does not give the response to a page of another origin.
- `surf build --single-file` and the playground put the script and the styles into the page. `<` before `/script` or `!--` becomes `\x3C`, and `<` before `/style` becomes `<\`, in each case of the letters. So a string of the app cannot end the script of the page.
- `surf upgrade` and `scripts/install.sh` check the SHA-256 of the archive with `SHA256SUMS` of the same release. This check finds a broken download, not a changed release. The Rust toolchain and the runtime pack have their SHA-256 in `surf`.
- `surf upgrade` installs only a newer version, so a release server cannot give back an older version with a known fault.
- A download of `surf` from an `https` address does not follow a redirect to `http`.
- A new runtime pack removes the old versions in `toolchain` and `runtime` of `SURF_HOME`, and the build results in `target`. A folder with another name stays, and `target` stays when Cargo did not make it.
- `surf install` runs no code of a package, and it refuses a symbolic link. A native build compiles the `.native.rs` files of a package, so use a package that you trust.
- The editor extensions start `surf` from the settings of the user or from the `PATH`. A folder cannot choose this program.
