This page covers what changes when the application runs behind a reverse
proxy or a load balancer: where the client’s address comes from, how to
rate limit by it, and what HTTPS that ends at the proxy means for cookies
and security headers.
ctx.server.requestIP(ctx.req) is the address at the other end of the
connection. Behind a proxy, that is the proxy, for every request.
The proxy passes the client’s address in x-forwarded-for. Each proxy on
the way appends the address it received the request from:
x-forwarded-for: <what the client sent>, <client>, <proxy 1>
The client can put anything at the start of the header, so only the
entries your own proxies appended can be trusted. They are at the end: the
client’s address is the Nth entry from the end, where N is the number of
your proxies in front of the server.
Tetsu does not parse this header, because N is a fact about your
deployment, and a wrong default fails silently: count one proxy too few and
every client shares a proxy’s address; take the first entry and every
client picks its own.
Write N once, in a hook that adds the address to the context:
const
consttrustedHops:1
trustedHops=1;
exportconstclientIp= hook.beforeParse((ctx) => {
const
constchain:string|null
chain= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req)?.
address: string |undefined
The IP address of the client.
address };
}
return {
clientIp: string |undefined
clientIp:
constchain:string
chain.split(",").at(-
consttrustedHops:1
trustedHops)?.trim() };
});
With one load balancer, trustedHops is 1. With a CDN in front of the
load balancer, it is 2. Without the header, as on a developer’s machine,
the connection’s address is the client’s.
This holds only when every request passes through all your proxies. Keep
the server reachable only from the last one, with a private network or a
firewall rule. If a proxy overwrites a header of its own, such as
x-real-ip in a common nginx setup, you can read that instead, on the
same condition.
Mount the hook first in the application’s beforeParse:
createApp({
hooks: { beforeParse: [clientIp] },
reportError: ({
source: FailureSource
source,
error: unknown
What was thrown, exactly as it was thrown: not formatted, not
truncated, so a logger's redaction sees the fields it knows.
error, ctx }) =>
constlogger: {
error(fields:object, message:string):void;
}
logger.error({
err: unknown
err:
error: unknown
What was thrown, exactly as it was thrown: not formatted, not
truncated, so a logger's redaction sees the fields it knows.
error,
source: FailureSource
source,
clientIp: string |undefined
clientIp: ctx?.
clientIp?: string |undefined
clientIp }, "tetsu"),
routes: object
The topology: a group, a controller, or an array of either.
routes,
});
The application’s later hooks see ctx.clientIp typed, and so does
reportError, where it is optional because a failure may come before the
hook ran. A route handler that reads it mounts the hook on the route, or
declares Requires<{ clientIp: string | undefined }> — see
Context and its types.
requestId() ignores an incoming x-request-id by default, since a
client could stamp another client’s log lines. If your proxy sets the
header on every request, requestId({ trustIncoming: true }) keeps the
proxy’s id, so its logs and the application’s share it. See
@tetsujs/request-id.
Bun.serve without a hostname listens on IPv4 and IPv6 at once, and
reports an IPv4 client as ::ffff:203.0.113.7, not 203.0.113.7.
As a rate limit key, that is harmless. Compared with an address written
the usual way, such as a proxy’s address in a list, it never matches, and
nothing reports it. Strip the prefix before comparing.
A stricter address hook needs this. It trusts x-forwarded-for only on a
connection from one of your proxies, and takes the connection’s address
otherwise, which also covers a request that bypassed them:
const
constproxies:Set<string>
proxies=newSet(["10.0.0.2", "10.0.0.3"]);
exportconstclientIp= hook.beforeParse((ctx) => {
const
constpeer:string|undefined
peer= ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req)?.
address: string
The IP address of the client.
address.replace(/^::ffff:/, "");
const
constchain:string|null
chain= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
Without the replace, proxies.has(peer) is false for every IPv4
request, and the hook reports the proxy as the client. at(-1) assumes one
proxy, as trustedHops = 1 does above.
The other way out is hostname: "0.0.0.0", which listens on IPv4 only and
turns IPv6 clients away. Tests have the same choice — see
Testing.
rateLimit() has no default key, because behind a balancer the
connection’s address is the balancer’s, and every client would share one
bucket; see
Choosing a key. Count by the
address the hook worked out. The limiter declares the field
with Requires, and the compiler checks that it is mounted after the hook:
importtype { Requires } from"@tetsujs/core";
import { rateLimit } from"@tetsujs/rate-limit";
constlimit=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 100,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
The topology: a group, a controller, or an array of either.
routes });
A key of undefined skips the limit for that request. With the first hook,
that happens when the chain is shorter than trustedHops, which means a
request bypassed a proxy.
Several instances each count in their own memory by default, so a client
gets the limit once per instance. A
shared store, such as Redis,
makes it one budget.
A proxy usually terminates TLS: the client talks HTTPS to it, and it talks
plain HTTP to the application. Two things follow.
ctx.req.url says http:, and its host is whatever the proxy put in
host. Build absolute URLs the application sends out, such as links in
emails, from a public origin in your configuration, not from the request.
x-forwarded-proto reports the client’s scheme, if your proxy sets it.
secure cookies still work.secure: true tells the browser to send
the cookie over HTTPS only, and the browser’s connection to the proxy is
HTTPS. Set it the same way with or without a proxy.
Bun can also terminate TLS itself with the tls option of Bun.serve.
secureHeaders() from @tetsujs/secure-headers sends
strict-transport-security on every response, which tells browsers to use
HTTPS for the site from then on, for 180 days by default. Browsers ignore
it on plain HTTP responses, so the application does not need to know
whether TLS ended in front of it.
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 } });
createApp({ hooks: { beforeResponse: [secure] },
routes: object
The topology: a group, a controller, or an array of either.
routes });
includeSubDomains and preload are off by default: the first breaks any
subdomain still on plain HTTP, and the second takes months to undo. See
@tetsujs/secure-headers.