security
Stop other sites from sending requests with your users' cookies (CSRF, cross-site request forgery). Let the browser apps you choose call your API from another origin (CORS, cross-origin resource sharing).
Add it whenever the browser attaches a login on its own, like a cookie. With a login in an Authorization header, you need it only for CORS.
Adding the Middleware
Add mionSecurity from @mionjs/router/middlewares, first. A preflight (the browser's check request before a cross-origin call) carries none of your headers, so an auth middleware before it would refuse it.
import {HeadersSubset} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {mionSecurity, mionFetchMetadata} from '@mionjs/router/middlewares';
const mion = createMionRouter();
type User = {name: string; surname: string};
const routes = {
// first, before the auth middleware
mionSecurity: mionSecurity({
origins: ['https://app.example.com'],
cors: {credentials: true, maxAge: 600},
}),
mionFetchMetadata,
auth: mion.headersMiddleware(
(ctx, {headers}: HeadersSubset<'Authorization'>): void => undefined
),
// `x-ratelimit-remaining` is exposed to the browser with no CORS setting
rateLimit: mion.middleware(
(ctx): HeadersSubset<'x-ratelimit-remaining'> =>
new HeadersSubset({'x-ratelimit-remaining': '99'})
),
listUsers: mion.query((ctx): User[] => [{name: 'John', surname: 'Doe'}]),
} satisfies Routes;
const myApi = mion.initRoutes(routes);
export type MyApi = typeof myApi;
mionSecurity covers every route. Inside a group, it covers only that group.Security Options
import {HeadersSubset} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {mionSecurity} from '@mionjs/router/middlewares';
const mion = createMionRouter();
const routes = {
mionSecurity: mionSecurity({
// asked only when a request's origin matters; may be async, like a lookup in your database
origins: async (ctx, origin) => origin.endsWith('.example.com'),
cors: {
// a raw middleware reads and sets these by hand, so no type declares them
allowHeaders: ['x-request-id'],
exposeHeaders: ['x-response-time'],
// declared below, still sent, but a browser on another origin cannot read it
excludeHeaders: ['x-served-by'],
},
}),
timing: mion.rawMiddleware((ctx): void => {
ctx.response.headers.set('x-response-time', '3ms');
}),
servedBy: mion.middleware(
(ctx): HeadersSubset<'x-served-by'> =>
new HeadersSubset({'x-served-by': 'eu-1'})
),
} satisfies Routes;
| Option | What it does |
|---|---|
origins | Origins you trust: a list, '*' for any, or a function (ctx, origin) returning a boolean. They may send requests from another site and, with cors, read the answers. null is never trusted. |
cors | Lets the trusted origins read answers. Off by default. Needs origins, or it throws. |
cors.credentials | Lets the browser send cookies. Needs listed origins: '*' with credentials throws. |
cors.maxAge | Seconds the browser keeps a preflight answer before asking again. |
cors.allowHeaders | Extra request headers a raw middleware reads. |
cors.exposeHeaders | Extra response headers a raw middleware sets. |
cors.excludeHeaders | Headers the browser never reads or sends cross-origin, even when a route declares them. Wins over allowHeaders and exposeHeaders. |
csrf | The checks below, all on by default. csrf: false turns them all off. |
CSRF Checks
Each check refuses a request another site can make a browser send. A call from the mion client always passes.
import {createMionRouter, Routes} from '@mionjs/router';
import {mionSecurity} from '@mionjs/router/middlewares';
const mion = createMionRouter();
const routes = {
// no options: same-origin browsers and non-browser callers pass, CORS is off
mionSecurity: mionSecurity(),
getUser: mion.query((ctx, id: number): string => `user ${id}`),
// POST only: a link or an image on another site never runs it
deleteUser: mion.mutation((ctx, id: number): boolean => true),
} satisfies Routes;
| Check | Refuses | Answer |
|---|---|---|
methodByIntent | A method the route's intent does not take: a query takes GET, HEAD and POST; a mutation, a route and a batch take only POST. | 405 method-not-allowed, with an Allow header |
crossOrigin | A POST another site sent, unless its origin is trusted. Read from the browser's Sec-Fetch-Site header, else from Origin against Host. A caller sending neither (a server, a script, curl) passes. | 403 cross-origin-request-refused |
| JSON body, no option of its own | A body not sent as application/json, like a plain HTML form or a fetch with no type. | 415 unsupported-media-type |
CORS Headers From Your Types
mionSecurity fills the CORS headers from your routes' types. Adding a header to a route needs no change here.
| CORS header | Holds |
|---|---|
Access-Control-Allow-Headers | content-type, every header a headers middleware reads, and allowHeaders |
Access-Control-Expose-Headers | Every header a route or middleware returns, mion's x-rpc-error and x-build-version, and exposeHeaders |
Access-Control-Allow-Methods | GET, POST for a query, POST for the rest |
cors: false to a route or middleware to keep all its headers off both lists, like excludeHeaders. It needs MionSecurityHandlerOptions in your router's handler part, see Custom Router Types.Answers per Request
| Request | Answer |
|---|---|
| From a trusted origin | The route runs; with cors, the answer allows the origin and lists the exposed headers. |
| A GET from another origin | The route runs, with no CORS headers, so the browser hides the answer. |
| A POST from another origin | Refused with a 403. |
A preflight (OPTIONS) | Answered 200 by the middleware, CORS on or off. The route never runs. |
| A refused request or an error | The same CORS headers, so the client can read the error. |
| Every answer | x-content-type-options: nosniff, and no server: mionjs header. A server value you set in responseHeaders stays. |
A request whose body length is unclear: a repeated or non-numeric content-length, or a transfer-encoding other than chunked | Refused with a 400 unclear-body-length, and the connection is closed. |
| An answer that depends on request headers | Vary lists every header a headers middleware reads, plus Origin with cors. A shared cache then never gives one user's answer to another. |
What the Middleware Protects
The CSRF checks stop other sites from making the browser act for your users. CORS decides which pages may read your answers. Neither is access control: a server, a script or curl calls your API with no browser. Keep your auth middleware.
excludeHeaders to hide a secret.What Your Server or Proxy Handles
mion does not handle these. Set them up in your server, your proxy or the platform in front.
| Concern | Where to handle it |
|---|---|
HTTPS and Strict-Transport-Security | Your proxy, or the router's responseHeaders option. |
| Rate limits and request floods | Your proxy, API gateway or platform. One server process cannot count requests across instances. |
| Slow clients and timeouts | The server options of your platform, or your proxy. |
Cookie flags (Secure, HttpOnly, SameSite) | The middleware that sets the cookie. |
Caching rules (Cache-Control) | A route's headers, or responseHeaders with no-store for every answer. |
Proxy headers (X-Forwarded-For, X-Forwarded-Host) | Your proxy. mion never reads them. |
Calling From Another Origin
The client needs no setup. To send cookies, set credentials: 'include' in fetchOptions.
import {isRpcError} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {MyApi} from '../router/security.routes.ts';
const {routes} = initClient<MyApi>({
baseURL: 'https://api.example.com',
// needs `credentials: true` on the server
fetchOptions: {credentials: 'include'},
});
const [users, error, response] = await routes.listUsers().call();
if (!error) console.log(users);
// a header a middleware returned, at its path
const {rateLimit} = response;
if (rateLimit && !isRpcError(rateLimit))
console.log(rateLimit.headers['x-ratelimit-remaining']);