Security
What brumate checks for you, and what stays your job: calls from other sites, errors, untrusted input in markup, and inline scripts.
Calls from other sites
A server function call and a form post send the cookies of the user, so a page of another site must not be able to make them. brumate refuses them with status 403.
| Checked | Not checked |
|---|---|
Server function calls, at /_brumate/<id> |
Endpoints, such as api/todos.ts |
| Form posts to a server function | Middleware |
Form posts to a page with ?__action=<id> |
Pages that answer GET |
A request comes from another site when:
- Its
Originheader isnull, or names a host other than the host of the request. The host of the request is read from its URL, and from theHostandX-Forwarded-Hostheaders, so a server behind a proxy matches its public name. - It has no
Origin, andSec-Fetch-Siteiscross-siteorsame-site. Older browsers send only this header.
same-site counts as another site, because a page on another subdomain can be run by someone else.
A request with neither header does not come from a page in a browser, so it is allowed. The check runs before any middleware, so a refused call never reaches src/middleware.ts. Origins in the origins option of the plugin are always allowed:
brumate({ origins: ['https://app.example.com'] });
An endpoint that changes data checks itself with isCrossSite:
// src/routes/api/todos.ts
import { isCrossSite } from 'brumate/server';
export async function POST(request: Request): Promise<Response> {
if (isCrossSite(request)) {
return new Response('Forbidden', { status: 403 });
}
// …
}
Rate limits
Server functions and endpoints answer anyone. rateLimit counts the calls of each key and refuses the ones over the limit with 429. A sign-in counts by the account that it guesses at, and an action of a signed-in user by the user. Otherwise it counts by the address of the client, which comes from the server, or from X-Forwarded-For when the server saw one of the proxies that the app names, never from a header alone. Without a key or an address, a limit throws in production rather than count every caller together. See Rate limiting.
Secrets
The server variables of src/env.ts never ship. The browser gets the file without them, and only components import it in code that the browser loads. The compiler reports code that runs in the browser reading one, or a value made from one that the page state would send. The server refuses to send a page whose state holds the value of one, from any code, such as a helper. See Environment variables.
Errors
- A
ServerErrorsends its message and status to the browser. Write messages for the user. - Any other error is logged on the server. The browser gets status 500 and the message "The server function failed", so server details stay on the server.
- Errors that are sent as values, such as in a memo, keep their name and message only. Stack traces are never sent.
- A route error renders
_error.brmwith the same rules.
Untrusted arguments
The arguments of a server function and the fields of a form come from the browser. Their types are not checked when they arrive. A user can send any value that seroval reads.
- Check them with
parse,safeParseorparseFormfrombrumate/serverand a Standard Schema. See Validating arguments. parseanswers with status 400 and the message of the first issue, cut at 300 characters. The issues stay on the server.- Issues that
safeParseandparseFormreturn keep only the message and the path, so fields that a library adds, such as the input, are not sent back. parseFormrefuses names with a part named__proto__,constructororprototype, so a form cannot change the prototype of an object. Its work grows with the size of the form only.- The schema runs on the input, so its own checks decide how long it takes. Prefer limits on the length of strings and arrays.
Regular expressions
Regular expressions are not read or written, in the page or in server function calls. A call that sends one fails with status 500. A pattern from a client could take the server a long time to match.
Markup
- Inserted values and attribute values are escaped.
raw(html)frombrumate/htmlinserts HTML without escaping. Insert only HTML you trust, such as Markdown that you rendered from your own files.- URLs in attributes are not checked. A link to a
javascript:URL from user input runs code when it is clicked. Check the scheme of a URL from a user before it goes intohreforsrc. - The page state is code in inline scripts. seroval escapes every
<in its strings, and U+2028 and U+2029, so a value cannot end the script or run as code. The server refuses a record that would end its script.
Content Security Policy
A page runs two kinds of inline scripts:
- The state records, one or more per render.
- The scripts that swap in
#loadingbodies.
Give them a nonce with the nonce option of render, renderToStream or the handle of virtual:brumate/routes:
const nonce = crypto.randomUUID();
const response = await handle(request, {
nonce,
scripts: `<script type="module" async src="/assets/client.js" nonce="${nonce}"></script>`,
});
response?.headers.set('content-security-policy', `script-src 'self' 'nonce-${nonce}'`);
The client code loads as modules from your origin, which 'self' allows.
A policy from a header stays for the whole visit, so the client router gives the state records and swap scripts of each later page the nonce of the page that the browser loaded. Each response can still make its own nonce.
A policy with hashes instead of a nonce does not work for the state records. Their code holds the values of the page, so each page has other hashes, and the records that stream later are not known when the headers are sent. Use a nonce, or 'unsafe-inline'.
A prerendered page is the exception. Its scripts are known at build time, and the build writes their hashes. See Content Security Policy for prerendered pages.
Trusted Types
A policy with require-trusted-types-for 'script' refuses a string in innerHTML and in the text of a script. The core inserts the markup that it renders, such as a body that an #if opens, and copies the scripts of a #defer body or of a page that the client router loads, so it needs a Trusted Types policy. It makes one named brumate, once, when it first inserts markup, and only where the browser has Trusted Types. Allow it next to the nonce:
script-src 'self' 'nonce-…'; require-trusted-types-for 'script'; trusted-types brumate
The policy passes markup and scripts as they are: the markup is what brumate's own render code made, with every value escaped, and the scripts came from the server with the page. HTML that an app inserts with raw goes through it too, so the app stays responsible for what it holds. Without trusted-types brumate, the core logs which directive is missing, and the inserts fail as the browser refuses them.