Skip to the content

SurfWave

Security

The limits of the server, their settings, and what an app must do itself.This page as Markdown
Statuscurrent on 2026-11-03.
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:
  • Do TLS, and send Strict-Transport-Security.
  • Limit the rate and the connections of each client. The server sees only the address of the proxy.
  • 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.