Bosia Bosia v1.2.9

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/Referer on 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.com

Exempting webhook paths (`CSRF_EXEMPT_PATHS`)

Server-to-server webhooks (Stripe, Xendit, GitHub, …) POST from another server, so they carry no Origin or Referer header and are rejected by the origin check. List their paths in CSRF_EXEMPT_PATHS to let them through:

CSRF_EXEMPT_PATHS=/webhook/xendit, /webhook/stripe

Matching is exact or on a path boundary: /webhook covers /webhook and everything under /webhook/…, but not a lookalike like /webhooky. Use a specific path (/webhook/xendit) to open just one route, or a prefix (/webhook) to open a group.

An exempt path bypasses CSRF entirely — the route MUST authenticate the caller itself. Verify the provider's webhook token or signature (e.g. compare an x-callback-token header against a secret) before acting on the request. CSRF is not a substitute for that check; it never protected server-to-server calls in the first place.

Reverse-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=true

Only 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-Proto reflecting the public origin.

With it on, Bosia rebuilds the request URL from those headers, so event.url, absolute redirects, the CSRF and WebSocket origin checks and the Secure cookie flag all use the public origin. A chained value (https, http) uses the first entry; ws/wss (Traefik on upgrades) count as http/https. A malformed host or an unknown scheme is ignored.

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.com

Additional 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=86400

Preflight 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=true

If 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:

  1. A matching Content-Security-Policy header is added.
  2. The framework's own <script> tags — page-data hydration, theme bootstrap, dev SSE reload, plugin head/body fragments emitted via the framework — get nonce="…" 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 request

Use 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 };
}

This includes inline scripts in <svelte:head>: they ship in the server HTML and run on first load, so without the nonce the policy blocks them.

Escape < when you put data into a script with {@html}, for example JSON-LD. Otherwise a value containing </script> closes the block early and whatever follows runs as a real script:

const ld = JSON.stringify(data).replace(/</g, "\\u003c");

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=Lax

Path defaults to the mount — / normally, or BASE_PATH when the app is mounted under a sub-path. That scoping is what keeps a session out of a neighbouring app sharing the same origin, so don't hardcode path: "/" to undo it. cookies.delete() with no path matches the same default.

To 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 \n are rejected
  • SameSite validation — only Strict, Lax, or None are 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.

Route Guards in Hooks

Guard signed-in pages in src/hooks.server.ts, by route id:

// src/hooks.server.ts
import { redirect, type Handle } from "bosia";

export const handle: Handle = async ({ event, resolve }) => {
	event.locals.user = await getUser(event.cookies.get("session"));
	if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
		throw redirect(303, "/login");
	}
	return resolve(event);
};

event.route.id is the route's folder under src/routes, with groups kept: a page at src/routes/(private)/dashboard/[id]/+page.svelte has the id /(private)/dashboard/[id]. A page added under (private) later is guarded without touching the hook. Check the id, not event.url.pathname: groups don't appear in the URL.

handle runs before anything else for the request: metadata(), every loader, form actions and API handlers. A refused visitor runs none of the page's code. The same check covers full page loads, client-side navigations, form action POSTs and API routes (/api/(admin)/users).

Guarding in hooks is the rule. Don't use +layout.server.ts as the gate. metadata() starts at the same time as the layout loaders, so a redirect thrown from a layout still sends the visitor away, but the page's metadata() has already run for them. So has the page's load(), up to its first await parent(): all loaders start together. Checks that depend on the data itself ("does this user own this post?") belong in the loader or action that reads it.

parent() always returns data produced on the server. The browser can't supply it.

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: each part may use only ASCII letters, digits, -, _, . and ~ (/ only inside a catch-all [...rest]), and empty parts or parts starting or ending with . (., .., v1., .env) are rejected: Windows drops trailing dots and static hosts hide dotfiles. A value that gives a URL the server answers itself fails the build, since the saved file would hold the server's reply instead of the page: /_health, and for pages a static-file ending such as .png, .css or .txt. A URL can't start with dist or __bosia either, in any case (DIST/x too): those are the framework's folders, and on macOS or Windows a DIST/ page folder would swallow the JS/CSS bundle. A Windows device name (con, nul, com1) is rejected, and so is a last part of index or one ending in .html or .json. A page may not write the same file as another (a static /about and a slug about), and a slug can't replace the saved 404.html (a static src/routes/404 page may). Two values that differ only by case (About and about, or folders A/ and a/) fail too, since macOS and Windows treat them as one; a repeated value is simply written once. So 50%-off, café or what%3F fail; turn them into a slug like 50-off. A missing value is rejected too. Any entries() error, or a missing entries() export, fails the build. A build that returns an unsafe value fails fast with a clear error rather than silently writing HTML outside the output tree.

Prerender also checks what the server answers. A page must reply 2xx with HTML, and an API 2xx; a loader's error(500), a redirect(307, "/login"), a hook that answers a file, or a timeout fails the build with one ❌ line per URL, so a broken page is never saved and served as a snapshot. A slug must also reach its own route: /blog/[slug] giving new fails when src/routes/blog/new exists, and so does a page slug where a +server.ts answers, since saving it would freeze that live route. Case is ignored here, because a macOS or Windows host serves the saved file for every spelling of its URL: a slug New fails next to blog/new, a prerendered /about next to a live /About, and /blog/new next to /[x]/NEW (which answers /BLOG/NEW). Routes that overlap only through params ([slug], [...rest]) are allowed.

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.

The location is read the way a browser reads it, so tricky spellings can't slip through: java\tscript: is still javascript:, and /\evil.com, htt\tps://evil.com and http:evil.com all count as external. A location that isn't a valid URL (https://exa mple.com) throws invalid URL, also with allowExternal: true. Encode it first, e.g. with new URL(...).

goto() follows the same scheme rule on the client: a javascript:, data: or vbscript: URL makes its Promise reject. Unlike redirect(), goto() still navigates to other sites, so check a user-supplied ?next= stays on your origin before passing it.

No page is served at a path starting with //: a request for //evil.com/x gets a 308 to /evil.com/x. Otherwise a redirect that echoes the path (like the trailing-slash one) would send the browser to evil.com.

Production Error Handling

In production (NODE_ENV=production), stack traces are stripped from error responses to prevent leaking internal details to clients.