brumate

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:

  1. Its Origin header is null, or names a host other than the host of the request. The host of the request is read from its URL, and from the Host and X-Forwarded-Host headers, so a server behind a proxy matches its public name.
  2. It has no Origin, and Sec-Fetch-Site is cross-site or same-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

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.

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

Content Security Policy

A page runs two kinds of inline scripts:

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.