Security in production
In production an app usually sits behind a proxy that holds the certificate and passes requests on. This page is about what Spider does on that road before a handler runs, what is on by default, and what the app has to say.
The code is from examples/guide (source): a small server kept as tests, for what the example apps do not use.
const spider = @import("spider");
pub const config = spider.Config{
.views_dir = "./src",
// The proxy in front of the app: its X-Forwarded-For is believed, and
// nobody else's. The tests talk to the server from this address.
.trusted_proxies = &.{"127.0.0.1"},
// A payment provider posts to /hooks/... from its own site, in the
// visitor's browser: the one place a cross-site POST is expected.
.origin_check = .{ .exempt_paths = &.{"/hooks/"} },
// Nothing larger than this is read: an upload over it gets a 413.
.max_body_bytes = 1024 * 1024,
};
/// What a main() would do to serve. The tests start it once
/// (`spider.testing.start(run)`) and share it.
pub fn run() !void {
// One client for the bucket, made once. In an app the values come from
// the environment: `spider.r2.R2.initFromEnv(io)`.
// (`io` is the one main() received; this package has no main.)
const io = std.Options.debug_io;
store = try spider.r2.R2.init(io, .{
.account_id = "0a1b2c3d4e5f",
.access_key = "an-access-key-id",
.secret_key = "a-secret-access-key",
.bucket = "guide-files",
.pub_url = "https://files.guide.example",
});
defer store.deinit();
var server = spider.app(.{});
defer server.deinit();
try server
.use(spider.loggerWith(.{ .quiet_paths = &.{ "/up", "/assets/**" } }))
.use(spider.forceHttps(.{ .default_base_url = "https://guide.example" }))
.use(spider.varyHtmx)
.get("/whoami", whoami, .{ .public = true })
.post("/notes", addNote, .{ .public = true })
.post("/hooks/payments", paymentHook, .{ .public = true, .allow_http = true })
.post("/photos", addPhoto, .{ .public = true })
.get("/photos/report", photoReport, .{ .public = true })
.sseWith("/ticks", ticks, .{ .public = true })
.wsWith("/rooms/:room/chat", chat, .{ .public = true })
.wsWith("/staff/feed", staffFeed, .{ .authenticated = true })
.get("/orders.xlsx", exportOrders, .{ .public = true })
.post("/orders/import", importOrders, .{ .public = true })
.get("/tickets/:code/qr.svg", ticketQr, .{ .public = true })
.post("/avatars/sign", signAvatarUpload, .{ .public = true })
.get("/invoices/:number", invoice, .{ .public = true })
.post("/reports", keepReport, .{ .public = true })
.get("/auth/google", googleLogin, .{ .public = true })
.get("/auth/google/callback", googleCallback, .{ .public = true })
.get("/shop", shop, .{ .public = true })
.get("/shop/typo", shopTypo, .{ .public = true })
.get("/shop/receipt", receipt, .{ .public = true })
.get("/tasks", tasks, .{ .public = true })
.post("/tasks", addTask, .{ .public = true })
.post("/tasks/:id/archive", archiveTask, .{ .public = true })
.onError(spider.errorHandler(.{}))
.listen(.{});
}
Posts from another site
A page on another site can make a visitor's browser post a form to your app, with the visitor's cookies: that is CSRF. Spider refuses it before any route is looked at, and it is on by default. There is no token to put in forms.
A browser says where a request comes from. A request that changes something (anything but GET, HEAD and OPTIONS) and comes from another site gets a 403. A warning in the log says which request and from where. The app's own pages pass, and so does a program that is not a browser (a script, a webhook, another server): it carries nobody's cookies.
test "cross-site: a form on another site cannot post here; the app's own pages and other programs can" {
const app = try spider.testing.start(run);
// A browser says where a request comes from. Another site: refused
// before any route is looked at.
var forged = try app.request(.{ .method = "POST", .target = "/notes", .body = "", .headers = &.{ "Sec-Fetch-Site: cross-site", "Origin: https://evil.example" } });
defer forged.deinit();
try forged.expectStatus(403);
// The app's own page.
var own = try app.request(.{ .method = "POST", .target = "/notes", .body = "", .headers = &.{"Sec-Fetch-Site: same-origin"} });
defer own.deinit();
try own.expectStatus(201);
// Not a browser (a script, another server): it carries nobody's
// cookies, so there is nothing to forge.
var program = try app.request(.{ .method = "POST", .target = "/notes", .body = "" });
defer program.deinit();
try program.expectStatus(201);
}
Some posts from another site are expected: a payment or login provider that sends the visitor's browser back with a form. Config.origin_check names them, by path (exempt_paths) or by who sends them (trusted_origins, full origins such as https://auth.example.com):
test "cross-site: a path the config exempts takes posts from anywhere" {
const app = try spider.testing.start(run);
var res = try app.request(.{ .method = "POST", .target = "/hooks/payments", .body = "", .headers = &.{ "Sec-Fetch-Site: cross-site", "Origin: https://pay.example" } });
defer res.deinit();
try res.expectStatus(200);
}
The same check refuses a WebSocket opened from another site.
HTTPS
The proxy talks to the app over plain HTTP and says in a header which one the visitor used. spider.forceHttps sends a visitor who came over HTTP to the same address on HTTPS:
test "https: a request that came over plain HTTP is sent to the HTTPS address" {
const app = try spider.testing.start(run);
// The proxy says which one the visitor used.
var plain = try app.with(&.{"X-Forwarded-Proto: http"}).get("/whoami?x=1");
defer plain.deinit();
try plain.expectRedirect("https://guide.example/whoami?x=1");
var secure = try app.with(&.{"X-Forwarded-Proto: https"}).get("/whoami");
defer secure.deinit();
try secure.expectStatus(200);
// A route that says `.allow_http` answers either way.
var hook = try app.request(.{ .method = "POST", .target = "/hooks/payments", .body = "", .headers = &.{"X-Forwarded-Proto: http"} });
defer hook.deinit();
try hook.expectStatus(200);
}
.default_base_urlis the public address of the app. TheBASE_URLvariable replaces it when it is set.- The header is
X-Forwarded-Proto(.proto_headerchanges it). A request without the header is taken as HTTPS: that is development, with no proxy. - A route with
.allow_http = trueis not redirected: for a caller that cannot follow a redirect, such as some webhooks..allow_http_pathsdoes the same for paths that are not routes.
The visitor's address
Behind a proxy, every connection comes from the proxy. The visitor's address is in the X-Forwarded-For header, which anyone can also write themselves. c.clientIp() believes the header only when the connection comes from a proxy listed in Config.trusted_proxies, and then takes the address that proxy saw:
/// The address of the visitor and the id of the request, as the app sees them.
fn whoami(c: *spider.Ctx) !spider.Response {
return c.json(.{
.ip = c.clientIp() orelse "unknown",
.peer = c.peerAddress() orelse "unknown",
.request_id = c.requestId(),
}, .{});
}
test "the visitor's address: what the trusted proxy wrote, not what the visitor claims" {
const app = try spider.testing.start(run);
// The proxy appends the address it saw to whatever came in the header,
// so the last entry is the one to believe: a visitor who sends
// "X-Forwarded-For: 6.6.6.6" does not become 6.6.6.6.
var res = try app.with(&.{"X-Forwarded-For: 6.6.6.6, 203.0.113.7"}).get("/whoami");
defer res.deinit();
try res.expectContains("\"ip\":\"203.0.113.7\"");
try res.expectContains("\"peer\":\"127.0.0.1\"");
}
trusted_proxies takes addresses or ranges ("10.0.0.0/8"). Empty, the default, the header is ignored and c.clientIp() is the address of the connection. Use c.clientIp() for logs, audit and rate limits, and never read the header yourself.
One id per request
Every request has an id. It is in the log line, in the X-Request-Id header of the answer and in c.requestId(), so a visitor's report, the proxy's log and the app's log can be matched. An id the proxy already gave is kept:
test "request id: the proxy's is kept, and comes back in the answer" {
const app = try spider.testing.start(run);
var given = try app.with(&.{"X-Request-Id: edge-7f3a"}).get("/whoami");
defer given.deinit();
try given.expectHeader("X-Request-Id", "edge-7f3a");
try given.expectContains("\"request_id\":\"edge-7f3a\"");
// Without one, Spider makes it up: 16 hex digits.
var made = try app.get("/whoami");
defer made.deinit();
try std.testing.expectEqual(@as(usize, 16), made.header("X-Request-Id").?.len);
}
Limits that are already on
Set in spider.config.zig; the values are the defaults.
max_body_bytes(10 MiB): a larger request body gets a 413, before it is read when it declares its length. Raise it for an app that takes files.header_timeout_ms(30 s): how long a client has to send the head of a request.body_timeout_ms(60 s): the longest silence while a body arrives. A slow but steady upload is not cut.keepalive_timeout_ms(120 s): how long an idle connection is kept. Keep it above the proxy's own idle time.stream_write_timeout_ms(10 s): a stream whose reader stopped reading is dropped.
None of them applies while a handler runs.
Who may call each route
A route says who may call it in its config (see Router and Policies). With require_route_access = true in the config, or server.requireRouteAccess(), the server refuses to start while a route says nothing, and names it: a forgotten rule becomes an error at start instead of an open door. spider routes --check does the same check from the command line.
What reaches the page
A template escapes every value it prints, and Markdown escapes what the author typed. Text reaches a page as HTML only through spider.RawHtml: search the code for it when reviewing.