Security
CSRF protection, CORS, security headers, cookie safety, and more.
Bosia includes several security features enabled by default.
CSRF Protection
All non-safe requests (POST, PUT, PATCH, DELETE) are validated against the server's origin. This uses the same approach as SvelteKit — checking the Origin or Referer header.
- Safe methods (GET, HEAD, OPTIONS) are exempt
- Missing
Origin/Refereron state-changing requests is rejected with 403 - Cross-origin requests from unexpected origins are blocked
Configuration
Allow additional origins via the CSRF_ALLOWED_ORIGINS environment variable:
CSRF_ALLOWED_ORIGINS=https://app.example.com, https://mobile.example.comReverse-proxy deployments (`TRUST_PROXY`)
By default Bosia does not trust the X-Forwarded-Host and X-Forwarded-Proto request headers when deciding whether a request's origin matches. A directly-exposed server would otherwise let any client spoof the expected origin via attacker-supplied forwarded headers, defeating the CSRF check.
When Bosia runs behind a reverse proxy or load balancer (nginx, Caddy, Cloudflare, an ALB, etc.) the public-facing host typically differs from the Host header the inner Bun server sees. In that case, set:
TRUST_PROXY=trueOnly enable this when all of the following are true:
- A proxy/load balancer sits in front of Bosia.
- That proxy strips any client-supplied
X-Forwarded-*headers before forwarding (most do by default; verify yours does). - The proxy adds its own
X-Forwarded-Host/X-Forwarded-Protoreflecting the public origin.
Do not set TRUST_PROXY=true when Bosia is internet-facing with no proxy in front, or when you can't verify the proxy strips inbound forwarded headers — that re-opens the spoofing window the default closes.
In dev mode, bun run dev runs a proxy in front of the inner app server on a different port. The dev proxy injects X-Forwarded-Host / X-Forwarded-Proto and sets TRUST_PROXY=true on the spawned app process automatically, so same-origin form submissions and POSTs work without any extra configuration.
CORS
CORS is disabled by default. Enable it by setting allowed origins:
CORS_ALLOWED_ORIGINS=https://app.example.com, https://admin.example.comAdditional CORS settings:
CORS_ALLOWED_METHODS=GET, POST, PUT, DELETE
CORS_ALLOWED_HEADERS=Content-Type, Authorization
CORS_EXPOSED_HEADERS=X-Request-Id
CORS_CREDENTIALS=true
CORS_MAX_AGE=86400Preflight OPTIONS requests are handled automatically when CORS is configured. The preflight also validates the requested method (Access-Control-Request-Method) and headers (Access-Control-Request-Headers) against CORS_ALLOWED_METHODS / CORS_ALLOWED_HEADERS. A preflight that asks for a method or header outside the allow-list is answered with a 403 (still carrying Access-Control-Allow-Origin and Vary: Origin), so misconfigured clients surface a clear "not allowed by CORS policy" error in browser devtools instead of falling through to a permissive 204.
When CORS is configured, every response includes Vary: Origin — even responses to origins that aren't on the allow-list. This stops shared caches (CDNs, browser HTTP cache) from accidentally serving a response that contains Access-Control-Allow-Origin: A to a request from a different origin B.
Security Headers
Bosia sets these headers on every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
SAMEORIGIN |
Referrer-Policy |
strict-origin-when-cross-origin |
Disabling `X-Frame-Options`
Set DISABLE_X_FRAME_OPTIONS=true to omit the X-Frame-Options header. This is intended for apps that are intentionally embedded as an iframe by a different origin — preview/proxy hubs, design tools, sandbox runners. The other security headers stay on.
DISABLE_X_FRAME_OPTIONS=trueIf you control the embedder and want stricter framing rules instead, prefer setting frame-ancestors via CSP_DIRECTIVES rather than removing X-Frame-Options.
Content Security Policy (nonce-based)
CSP is off by default — turning it on without the right directives breaks user inline scripts and third-party widgets. Opt in by setting the CSP_DIRECTIVES env var. The literal {nonce} placeholder is substituted with a fresh per-request nonce (128 bits of entropy, base64) on every response:
CSP_DIRECTIVES="default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'"Once CSP_DIRECTIVES is set, two things happen on every response:
- A matching
Content-Security-Policyheader is added. - The framework's own
<script>tags — page-data hydration, theme bootstrap, dev SSE reload, plugin head/body fragments emitted via the framework — getnonce="…"stamped on them so the policy doesn't break them.
Without CSP_DIRECTIVES, the framework emits neither the header nor the attribute (the attribute alone would be dead bytes — browsers only compare nonces when a policy header tells them to).
The same nonce is always exposed on the request event so user code can use it under any policy:
// In a +page.server.ts load() or a hook:
event.locals.nonce; // → "kJ3p1f...": unique per requestUse the nonce on your own inline scripts so they keep working under the policy:
<script nonce="{data.nonce}">
console.log("hello");
</script>Forward the nonce to the page via a load() function:
// +page.server.ts
export async function load({ locals }) {
return { nonce: locals.nonce };
}Cookie Security
Every cookies.set() call applies secure defaults automatically — no need to specify them manually:
| Option | Default | Description |
|---|---|---|
path |
"/" |
Available to all routes |
httpOnly |
true |
Not accessible via JavaScript |
secure |
true |
HTTPS only (auto-disabled in dev) |
sameSite |
"Lax" |
Protects against CSRF |
In dev mode, secure is automatically set to false so cookies work over http://localhost without browser rejection.
Set a cookie with just the values you care about — secure defaults are applied for everything else:
event.cookies.set("session", token, {
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// → Set-Cookie: session=...; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=LaxTo opt out of a default, pass it explicitly:
// Client-readable cookie (e.g. theme preference)
event.cookies.set("theme", "dark", {
httpOnly: false,
maxAge: 60 * 60 * 24 * 365,
});Additional protections:
- Header injection prevention — values containing
;,\r, or\nare rejected - SameSite validation — only
Strict,Lax, orNoneare accepted - Automatic encoding — cookie values are safely encoded with
encodeURIComponent
XSS Protection
JSON data embedded in server-rendered HTML is escaped using a safe serializer that:
- Escapes
<,>,&,',", and Unicode characters that could break out of script tags - Handles circular references gracefully
Request Body Limits
Request body size is limited by default to prevent denial-of-service:
BODY_SIZE_LIMIT=512K # default
BODY_SIZE_LIMIT=1M # 1 megabyte
BODY_SIZE_LIMIT=10M # 10 megabytes
BODY_SIZE_LIMIT=Infinity # no limit (not recommended)Supports K (kilobytes), M (megabytes), and G (gigabytes) suffixes.
Path Traversal Protection
Static file and prerendered page serving validates that resolved file paths stay within their allowed directories, preventing ../ traversal attacks.
At build time, prerender entries() values are also validated: .. and \ are never allowed in any segment, and / is only allowed inside catch-all ([...rest]) segments. A build that returns an unsafe value fails fast with a clear error rather than silently writing HTML outside the output tree.
Open-Redirect Protection
redirect(status, location) rejects external URLs by default. Pass { allowExternal: true } to opt in for legitimate external redirects (e.g. OAuth providers):
import { redirect } from "bosia";
redirect(303, "https://accounts.example.com/oauth", { allowExternal: true });Even with allowExternal: true, dangerous schemes — javascript:, data:, vbscript: — are always rejected. Those schemes are never legitimate redirect targets and could otherwise be abused to inject script execution into a redirect chain.
Production Error Handling
In production (NODE_ENV=production), stack traces are stripped from error responses to prevent leaking internal details to clients.