@tetsujs/secure-headers sets the response headers that tell a browser to
turn off content sniffing, framing and referrer leaks, and to use HTTPS only.
It is one beforeResponse hook, and its defaults are safe for a JSON API.
HSTS is sent on every response. Browsers ignore it over plain HTTP, so it
works the same behind any proxy.
includeSubDomains and preload are off by default. The first breaks any
subdomain still served over plain HTTP, for as long as maxAge says. The
second takes months and a browser release to undo. Turn them on when you know
they are safe:
constsecure=secureHeaders({
hsts?:false| HstsOptions |undefined
strict-transport-security, or false to leave it off.
Sent on every response, including those that arrived over plain HTTP,
where the browser is required to ignore it — so this needs to know
nothing about proxies or protocols to be correct.
hsts: {
maxAge?: number |undefined
Lifetime in seconds. 180 days by default.
Long enough to matter and short enough to be wrong about: the browser
refuses plain HTTP to this host until it expires, and there is no way
to reach the browsers that already heard it.
maxAge: 63_072_000,
includeSubDomains?: boolean |undefined
Whether every subdomain is covered too. Off by default.
A subdomain served over HTTP — a legacy box, a status page, a
certificate-less internal tool — stops being reachable the moment one
request to the parent carries this, for as long as maxAge says. It
is the right setting for most deployments and the wrong default for
any of them.
includeSubDomains: true } });
preload without includeSubDomains, or with a maxAge under one year
(31,536,000 seconds), makes secureHeaders() throw, because the browsers’
preload list would reject it.
No policy is sent by default: a wrong policy breaks pages, and the failure
only shows in the browser of whoever loads them. For an API that only answers
JSON, apiPolicy denies everything:
constsecure=secureHeaders({
contentSecurityPolicy?: string |false|undefined
content-security-policy. Off by default, and alone in that.
A policy is a statement about a document, and this framework answers
with documents only where an application chose to. A default strict
enough to be worth having would break the first page anyone serves,
including this repository's own documentation page — docsPage()
loads its renderer from a CDN and carries an inline script, so
default-src 'none' would blank it. A default loose enough not to
break it would protect nothing.
What breaks is also the wrong kind of breakage to inflict silently: a
policy is enforced in the browser, so the failure is a blank page and
a console message on someone else's machine, not an error on the
server.
An API that answers only JSON should still have one, and
If the same application also serves a page, such as the docs page of
@tetsujs/openapi, remove the policy for that
route:
const
constdocsPage:"/docs"
docsPage="/docs";
constallowDocs= hook.beforeResponse((ctx) => {
if (ctx.
route?: RouteInfo |undefined
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route?.
path: string |undefined
The path as the route declared it, with group prefixes joined and
:params left as they are — /api/users/:id, never /api/users/42.
path===
constdocsPage:"/docs"
docsPage) ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
headers.delete("content-security-policy");
});
constsecure=secureHeaders({
contentSecurityPolicy?: string |false|undefined
content-security-policy. Off by default, and alone in that.
A policy is a statement about a document, and this framework answers
with documents only where an application chose to. A default strict
enough to be worth having would break the first page anyone serves,
including this repository's own documentation page — docsPage()
loads its renderer from a CDN and carries an inline script, so
default-src 'none' would blank it. A default loose enough not to
break it would protect nothing.
What breaks is also the wrong kind of breakage to inflict silently: a
policy is enforced in the browser, so the failure is a blank page and
a console message on someone else's machine, not an error on the
server.
An API that answers only JSON should still have one, and
apiPolicy
is it.
contentSecurityPolicy: apiPolicy });
createApp({
hooks: { beforeResponse: [secure, allowDocs] },
routes: [docs({
info: DocumentInfo
Title, version and the rest of the document's info block.
info,
uiPath?:"/docs"|undefined
Where the page is served. Defaults to /docs.
uiPath:
constdocsPage:"/docs"
docsPage }), api()],
});
ctx.route.path is the route’s full path: mounted in a group under /api,
the page is /api/docs.
A route’s own beforeResponse hook runs after the application’s, so it can
change a header for that route. Setting the header in the handler does not
work: the handler runs first, and the application’s hook overwrites it.
constallowFraming= hook.beforeResponse((ctx) => {
ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
headers.set("x-frame-options", "SAMEORIGIN");
const
constpolicy:string|null
policy= ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
headers.get("content-security-policy");
if (
constpolicy:string|null
policy) {
ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "GET",
path: "/embeddable"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/embeddable",
hooks: { beforeResponse: [allowFraming] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Response
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: () =>page(),
});
When the policy has frame-ancestors, as apiPolicy does, browsers follow it
and ignore x-frame-options, so allowing a frame means changing both.
The package also exports the types SecureHeadersOptions, HstsOptions
and SecureHeadersHook. Type a hook with ReturnType<typeof secureHeaders>
rather than AnyHook, which erases the slot the hook belongs to.