bosia 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/add.ts +4 -2
- package/src/cli/block.ts +94 -0
- package/src/cli/fonts.ts +61 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/theme.ts +88 -0
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/cors.ts +57 -11
- package/src/core/csp.ts +47 -0
- package/src/core/csrf.ts +8 -5
- package/src/core/dev.ts +14 -2
- package/src/core/errors.ts +4 -3
- package/src/core/hooks.ts +37 -1
- package/src/core/html.ts +68 -26
- package/src/core/prerender.ts +11 -0
- package/src/core/renderer.ts +346 -35
- package/src/core/routeFile.ts +26 -0
- package/src/core/safePath.ts +14 -0
- package/src/core/server.ts +103 -15
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ─── Client-Side Loader Cache ─────────────────────────────
|
|
2
|
+
// Stores the result of each server loader run by stable id (the
|
|
3
|
+
// +page.server.ts / +layout.server.ts path emitted by codegen) along
|
|
4
|
+
// with the LoaderDeps record captured server-side. On each client
|
|
5
|
+
// navigation we use the cache + a "dirty" set populated by the
|
|
6
|
+
// `invalidate()` API to decide which loaders need to re-run, sending
|
|
7
|
+
// the decision as an `_invalidated=<bits>` query param so the server
|
|
8
|
+
// can skip the loaders that haven't conceptually changed.
|
|
9
|
+
//
|
|
10
|
+
// Cache lives in browser memory only — wiped on hard refresh.
|
|
11
|
+
|
|
12
|
+
import type { LoaderDeps } from "../hooks.ts";
|
|
13
|
+
|
|
14
|
+
export type CacheSnapshot = {
|
|
15
|
+
pathname: string;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
searchParams: Record<string, string | null>;
|
|
18
|
+
cookies: Record<string, string | undefined>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CacheEntry = {
|
|
22
|
+
nodeId: string;
|
|
23
|
+
data: Record<string, any>;
|
|
24
|
+
deps: LoaderDeps;
|
|
25
|
+
snapshot: CacheSnapshot;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type EvalContext = {
|
|
29
|
+
pathname: string;
|
|
30
|
+
params: Record<string, string>;
|
|
31
|
+
url: URL;
|
|
32
|
+
cookies: Record<string, string | undefined>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readDocumentCookies(): Record<string, string> {
|
|
36
|
+
const out: Record<string, string> = {};
|
|
37
|
+
if (typeof document === "undefined") return out;
|
|
38
|
+
for (const pair of document.cookie.split(";")) {
|
|
39
|
+
const idx = pair.indexOf("=");
|
|
40
|
+
if (idx === -1) continue;
|
|
41
|
+
const name = pair.slice(0, idx).trim();
|
|
42
|
+
const value = pair.slice(idx + 1).trim();
|
|
43
|
+
if (name) {
|
|
44
|
+
try {
|
|
45
|
+
out[name] = decodeURIComponent(value);
|
|
46
|
+
} catch {
|
|
47
|
+
out[name] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function liveContext(
|
|
55
|
+
pathname: string,
|
|
56
|
+
params: Record<string, string>,
|
|
57
|
+
url: URL,
|
|
58
|
+
): EvalContext {
|
|
59
|
+
return {
|
|
60
|
+
pathname,
|
|
61
|
+
params,
|
|
62
|
+
url,
|
|
63
|
+
cookies: readDocumentCookies(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capture only the values relevant to a loader's tracked deps. */
|
|
68
|
+
export function captureSnapshot(deps: LoaderDeps, ctx: EvalContext): CacheSnapshot {
|
|
69
|
+
const params: Record<string, string> = {};
|
|
70
|
+
for (const k of deps.params) params[k] = ctx.params[k] ?? "";
|
|
71
|
+
const searchParams: Record<string, string | null> = {};
|
|
72
|
+
for (const k of deps.searchParams) searchParams[k] = ctx.url.searchParams.get(k);
|
|
73
|
+
const cookies: Record<string, string | undefined> = {};
|
|
74
|
+
for (const k of deps.cookies) cookies[k] = ctx.cookies[k];
|
|
75
|
+
return { pathname: ctx.pathname, params, searchParams, cookies };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type DirtyState = {
|
|
79
|
+
all: boolean;
|
|
80
|
+
keys: Set<string>;
|
|
81
|
+
urls: Set<string>;
|
|
82
|
+
urlMatchers: Array<(u: URL) => boolean>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function shouldRerun(entry: CacheEntry, dirty: DirtyState, next: EvalContext): boolean {
|
|
86
|
+
if (dirty.all) return true;
|
|
87
|
+
const { deps, snapshot } = entry;
|
|
88
|
+
// Dirty keys
|
|
89
|
+
for (const k of deps.keys) {
|
|
90
|
+
if (dirty.keys.has(k)) return true;
|
|
91
|
+
}
|
|
92
|
+
// Dirty URLs (string match or predicate match)
|
|
93
|
+
if (deps.urls.length > 0) {
|
|
94
|
+
for (const u of deps.urls) {
|
|
95
|
+
if (dirty.urls.has(u)) return true;
|
|
96
|
+
for (const fn of dirty.urlMatchers) {
|
|
97
|
+
try {
|
|
98
|
+
if (fn(new URL(u))) return true;
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore malformed URL deps
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Param value changes
|
|
106
|
+
for (const k of deps.params) {
|
|
107
|
+
if (snapshot.params[k] !== (next.params[k] ?? "")) return true;
|
|
108
|
+
}
|
|
109
|
+
// Search param value changes
|
|
110
|
+
for (const k of deps.searchParams) {
|
|
111
|
+
if (snapshot.searchParams[k] !== next.url.searchParams.get(k)) return true;
|
|
112
|
+
}
|
|
113
|
+
// Cookie value changes
|
|
114
|
+
for (const k of deps.cookies) {
|
|
115
|
+
if (k === "*") {
|
|
116
|
+
// Broad cookie read — re-run on any cookie change. Detect by
|
|
117
|
+
// comparing the full incoming jar against the captured slice.
|
|
118
|
+
// In practice an empty captured slice will never equal the live
|
|
119
|
+
// jar once any cookie exists, so we re-run whenever cookies do.
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (snapshot.cookies[k] !== next.cookies[k]) return true;
|
|
123
|
+
}
|
|
124
|
+
// Pathname change for loaders that read the URL
|
|
125
|
+
if (deps.uses_url && snapshot.pathname !== next.pathname) return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ─── Public Invalidation API ──────────────────────────────
|
|
2
|
+
// Counterpart to SvelteKit's `invalidate()` / `invalidateAll()`. Marks
|
|
3
|
+
// loader cache entries dirty and triggers the App.svelte nav effect to
|
|
4
|
+
// re-run only the loaders whose dependencies were invalidated.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// import { invalidate, invalidateAll } from "bosia/client";
|
|
8
|
+
// await invalidate("app:user");
|
|
9
|
+
// await invalidate("/api/posts");
|
|
10
|
+
// await invalidate((url) => url.pathname.startsWith("/api/"));
|
|
11
|
+
// await invalidateAll();
|
|
12
|
+
|
|
13
|
+
import { appState } from "./appState.svelte.ts";
|
|
14
|
+
|
|
15
|
+
type InvalidateTarget = string | URL | ((url: URL) => boolean);
|
|
16
|
+
|
|
17
|
+
function bumpTick() {
|
|
18
|
+
appState.invalidationTick = appState.invalidationTick + 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mark a dependency as invalid; the next navigation (and any in-progress
|
|
23
|
+
* navigation that hasn't started its fetch yet) will re-run loaders that
|
|
24
|
+
* declared a matching `depends()` key, fetched a matching URL, or — when
|
|
25
|
+
* given a predicate — fetched any URL the predicate returns true for.
|
|
26
|
+
*
|
|
27
|
+
* Returns a promise that resolves after the nav effect has flushed.
|
|
28
|
+
*/
|
|
29
|
+
export function invalidate(target: InvalidateTarget): Promise<void> {
|
|
30
|
+
if (typeof target === "function") {
|
|
31
|
+
appState.dirty.urlMatchers.push(target);
|
|
32
|
+
} else {
|
|
33
|
+
const str = typeof target === "string" ? target : target.href;
|
|
34
|
+
const isUrl = str.startsWith("/") || str.includes("://");
|
|
35
|
+
if (isUrl) {
|
|
36
|
+
try {
|
|
37
|
+
// Normalize to an absolute URL — fetches the loader recorded are
|
|
38
|
+
// always absolute, so a relative `/api/foo` must be promoted here.
|
|
39
|
+
const abs = new URL(str, window.location.origin).href;
|
|
40
|
+
appState.dirty.urls.add(abs);
|
|
41
|
+
} catch {
|
|
42
|
+
appState.dirty.urls.add(str);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
appState.dirty.keys.add(str);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
bumpTick();
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mark every loader as invalid. Next navigation re-runs all of them.
|
|
54
|
+
*/
|
|
55
|
+
export function invalidateAll(): Promise<void> {
|
|
56
|
+
appState.dirty.all = true;
|
|
57
|
+
bumpTick();
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
@@ -2,11 +2,52 @@
|
|
|
2
2
|
// Supports `data-bosia-preload="hover"` and `data-bosia-preload="viewport"`
|
|
3
3
|
// on <a> elements or their ancestors.
|
|
4
4
|
|
|
5
|
+
import { findMatch } from "../matcher.ts";
|
|
6
|
+
import { clientRoutes } from "bosia:routes";
|
|
7
|
+
import { appState } from "./appState.svelte.ts";
|
|
8
|
+
import { liveContext, shouldRerun } from "./loaderCache.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the `_invalidated` mask bits for a target path using the current
|
|
12
|
+
* client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
|
|
13
|
+
* '0' = skip. Returns `null` when the route cannot be matched.
|
|
14
|
+
*/
|
|
15
|
+
export function buildMaskBits(path: string): string | null {
|
|
16
|
+
const url = new URL(path, window.location.origin);
|
|
17
|
+
const pathname = url.pathname;
|
|
18
|
+
const match = findMatch(clientRoutes, pathname);
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const ctx = liveContext(pathname, match.params, url);
|
|
21
|
+
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
22
|
+
const pageId = (match.route as any).pageId as string | null;
|
|
23
|
+
|
|
24
|
+
const layoutRunFlags = layoutIds.map((id) => {
|
|
25
|
+
if (id === null) return false;
|
|
26
|
+
const entry = appState.loaderCache.layouts[id];
|
|
27
|
+
if (!entry) return true;
|
|
28
|
+
return shouldRerun(entry, appState.dirty, ctx);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let pageRun = false;
|
|
32
|
+
if (pageId !== null) {
|
|
33
|
+
const entry = appState.loaderCache.page;
|
|
34
|
+
if (!entry || entry.nodeId !== pageId) pageRun = true;
|
|
35
|
+
else pageRun = shouldRerun(entry, appState.dirty, ctx);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
5
41
|
/** Builds the `/__bosia/data/…` URL for a given client path. */
|
|
6
|
-
export function dataUrl(path: string): string {
|
|
42
|
+
export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
7
43
|
const url = new URL(path, window.location.origin);
|
|
8
44
|
let p = url.pathname.replace(/\/$/, "");
|
|
9
|
-
|
|
45
|
+
let qs = url.search;
|
|
46
|
+
if (invalidatedBits) {
|
|
47
|
+
const sep = qs ? "&" : "?";
|
|
48
|
+
qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
|
|
49
|
+
}
|
|
50
|
+
return `/__bosia/data${p || "/index"}.json${qs}`;
|
|
10
51
|
}
|
|
11
52
|
|
|
12
53
|
export const prefetchCache = new Map<string, { data: any; ts: number }>();
|
|
@@ -33,7 +74,11 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
33
74
|
|
|
34
75
|
pending.add(path);
|
|
35
76
|
try {
|
|
36
|
-
|
|
77
|
+
// Send the same mask as a real client nav would so the server can skip
|
|
78
|
+
// loaders whose tracked inputs haven't changed. Falls back to running
|
|
79
|
+
// everything when the route can't be matched (e.g. external/unknown URL).
|
|
80
|
+
const maskBits = buildMaskBits(path) ?? undefined;
|
|
81
|
+
const res = await fetch(dataUrl(path, maskBits));
|
|
37
82
|
if (res.ok) {
|
|
38
83
|
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
39
84
|
const oldest = prefetchCache.keys().next().value;
|
package/src/core/cors.ts
CHANGED
|
@@ -13,8 +13,24 @@ export interface CorsConfig {
|
|
|
13
13
|
maxAge?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
|
|
17
|
-
const DEFAULT_HEADERS = "Content-Type, Authorization";
|
|
16
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
|
|
17
|
+
const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
|
|
18
|
+
|
|
19
|
+
function parseHeaderList(value: string): string[] {
|
|
20
|
+
return value
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Headers applied to *every* response when CORS is configured, regardless of
|
|
28
|
+
* whether the request Origin is allowed. Keeps caches/CDNs from serving a
|
|
29
|
+
* response with `Access-Control-Allow-Origin: X` to a different origin Y.
|
|
30
|
+
*/
|
|
31
|
+
export function applyCorsVary(headers: Headers): void {
|
|
32
|
+
headers.set("Vary", "Origin");
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
36
|
* Returns CORS response headers if the request Origin is in the allowed list.
|
|
@@ -48,22 +64,52 @@ export function getCorsHeaders(
|
|
|
48
64
|
|
|
49
65
|
/**
|
|
50
66
|
* Handles OPTIONS preflight requests.
|
|
51
|
-
*
|
|
67
|
+
*
|
|
68
|
+
* - Returns `null` if the request's Origin is missing or not allowed — the
|
|
69
|
+
* caller treats this as "not a CORS preflight we serve", avoiding leaking
|
|
70
|
+
* policy details to unknown origins.
|
|
71
|
+
* - Returns a 403 (carrying `Access-Control-Allow-Origin` + `Vary: Origin`)
|
|
72
|
+
* when the requested method or any requested header falls outside the
|
|
73
|
+
* configured allow-list. A 403 surfaces a clearer "not allowed by CORS
|
|
74
|
+
* policy" message in the browser than letting the OPTIONS request fall
|
|
75
|
+
* through to the default handler.
|
|
76
|
+
* - Otherwise returns a 204 with the standard preflight headers.
|
|
52
77
|
*/
|
|
53
78
|
export function handlePreflight(request: Request, config: CorsConfig): Response | null {
|
|
54
79
|
const base = getCorsHeaders(request, config);
|
|
55
80
|
if (!base) return null;
|
|
56
81
|
|
|
82
|
+
const allowedMethods = config.allowedMethods ?? DEFAULT_METHODS;
|
|
83
|
+
const allowedHeaders = config.allowedHeaders ?? DEFAULT_HEADERS;
|
|
84
|
+
|
|
85
|
+
const requestedMethod = request.headers.get("access-control-request-method");
|
|
86
|
+
if (requestedMethod) {
|
|
87
|
+
const upper = requestedMethod.toUpperCase();
|
|
88
|
+
const allowedUpper = allowedMethods.map((m) => m.toUpperCase());
|
|
89
|
+
if (!allowedUpper.includes(upper)) {
|
|
90
|
+
return rejectPreflight(base, `Method ${requestedMethod} not allowed by CORS policy`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const requestedHeadersRaw = request.headers.get("access-control-request-headers");
|
|
95
|
+
if (requestedHeadersRaw) {
|
|
96
|
+
const requested = parseHeaderList(requestedHeadersRaw).map((h) => h.toLowerCase());
|
|
97
|
+
const allowedLower = allowedHeaders.map((h) => h.toLowerCase());
|
|
98
|
+
const disallowed = requested.find((h) => !allowedLower.includes(h));
|
|
99
|
+
if (disallowed) {
|
|
100
|
+
return rejectPreflight(base, `Header ${disallowed} not allowed by CORS policy`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
const headers = new Headers(base as HeadersInit);
|
|
58
|
-
headers.set(
|
|
59
|
-
|
|
60
|
-
config.allowedMethods?.join(", ") ?? DEFAULT_METHODS,
|
|
61
|
-
);
|
|
62
|
-
headers.set(
|
|
63
|
-
"Access-Control-Allow-Headers",
|
|
64
|
-
config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS,
|
|
65
|
-
);
|
|
105
|
+
headers.set("Access-Control-Allow-Methods", allowedMethods.join(", "));
|
|
106
|
+
headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
|
|
66
107
|
headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
|
|
67
108
|
|
|
68
109
|
return new Response(null, { status: 204, headers });
|
|
69
110
|
}
|
|
111
|
+
|
|
112
|
+
function rejectPreflight(base: Record<string, string>, reason: string): Response {
|
|
113
|
+
const headers = new Headers(base as HeadersInit);
|
|
114
|
+
return new Response(reason, { status: 403, headers });
|
|
115
|
+
}
|
package/src/core/csp.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─── CSP Nonce ───────────────────────────────────────────
|
|
2
|
+
// Per-request cryptographic nonce. Embedded as `nonce="..."` on every
|
|
3
|
+
// framework-emitted <script> tag so that user code (or operators) can
|
|
4
|
+
// configure a `Content-Security-Policy` header with `script-src 'nonce-…'`
|
|
5
|
+
// and lock down inline-script execution without breaking framework
|
|
6
|
+
// hydration.
|
|
7
|
+
//
|
|
8
|
+
// 16 random bytes → base64 (22 chars after stripping `=` padding) gives
|
|
9
|
+
// the 128 bits of entropy recommended by the CSP spec.
|
|
10
|
+
|
|
11
|
+
const NONCE_BYTES = 16;
|
|
12
|
+
|
|
13
|
+
export function generateNonce(): string {
|
|
14
|
+
const bytes = new Uint8Array(NONCE_BYTES);
|
|
15
|
+
crypto.getRandomValues(bytes);
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
18
|
+
return btoa(binary).replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns ` nonce="…"` (with leading space) when `nonce` is non-empty, otherwise `""`. */
|
|
22
|
+
export function nonceAttr(nonce: string | undefined): string {
|
|
23
|
+
return nonce ? ` nonce="${nonce}"` : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Optional CSP Header ─────────────────────────────────
|
|
27
|
+
// Opt-in via `CSP_DIRECTIVES` env. The literal `{nonce}` placeholder in
|
|
28
|
+
// the configured value is replaced with the per-request nonce on each
|
|
29
|
+
// response. Empty/unset env → no `Content-Security-Policy` header.
|
|
30
|
+
//
|
|
31
|
+
// Example:
|
|
32
|
+
// CSP_DIRECTIVES="default-src 'self'; script-src 'self' 'nonce-{nonce}'"
|
|
33
|
+
|
|
34
|
+
export const CSP_DIRECTIVES_TEMPLATE: string | null = process.env.CSP_DIRECTIVES?.trim() || null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `true` when an operator has opted into CSP via `CSP_DIRECTIVES`. The
|
|
38
|
+
* framework gates *all* nonce-related wire output on this flag — without a
|
|
39
|
+
* matching `Content-Security-Policy` header the nonce attribute is just dead
|
|
40
|
+
* bytes, so we omit it.
|
|
41
|
+
*/
|
|
42
|
+
export const CSP_ENABLED: boolean = CSP_DIRECTIVES_TEMPLATE !== null;
|
|
43
|
+
|
|
44
|
+
export function buildCspHeader(nonce: string): string | null {
|
|
45
|
+
if (!CSP_DIRECTIVES_TEMPLATE) return null;
|
|
46
|
+
return CSP_DIRECTIVES_TEMPLATE.replace(/\{nonce\}/g, nonce);
|
|
47
|
+
}
|
package/src/core/csrf.ts
CHANGED
|
@@ -31,12 +31,15 @@ export function checkCsrf(
|
|
|
31
31
|
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
32
32
|
|
|
33
33
|
// Derive the expected origin.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
|
|
35
|
+
// directly-exposed server would otherwise let a client spoof its own origin
|
|
36
|
+
// via attacker-controlled forwarded headers. Behind a real reverse proxy
|
|
37
|
+
// (nginx, Caddy, Cloudflare) the operator opts in by setting the env.
|
|
38
|
+
const trustProxy = process.env.TRUST_PROXY === "true";
|
|
39
|
+
const forwardedHost = trustProxy ? request.headers.get("x-forwarded-host") : null;
|
|
38
40
|
const host = forwardedHost ?? request.headers.get("host");
|
|
39
|
-
const
|
|
41
|
+
const forwardedProto = trustProxy ? request.headers.get("x-forwarded-proto") : null;
|
|
42
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
40
43
|
const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
|
|
41
44
|
|
|
42
45
|
const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
|
package/src/core/dev.ts
CHANGED
|
@@ -94,6 +94,10 @@ async function startAppServer() {
|
|
|
94
94
|
PORT: String(APP_PORT),
|
|
95
95
|
// Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
|
|
96
96
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
97
|
+
// Dev proxy injects X-Forwarded-Host/Proto reflecting the public DEV_PORT, so CSRF
|
|
98
|
+
// origin checks must honour them. Safe in dev because the proxy controls these
|
|
99
|
+
// headers — no untrusted client can spoof them.
|
|
100
|
+
TRUST_PROXY: "true",
|
|
97
101
|
},
|
|
98
102
|
});
|
|
99
103
|
|
|
@@ -204,16 +208,24 @@ const devServer = Bun.serve({
|
|
|
204
208
|
);
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
// Proxy everything else to the app server
|
|
211
|
+
// Proxy everything else to the app server. Inject X-Forwarded-Host/Proto so
|
|
212
|
+
// the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
|
|
213
|
+
// app env above) reconstructs the public-facing origin from the dev proxy
|
|
214
|
+
// rather than the inner-app's host (localhost:APP_PORT).
|
|
208
215
|
try {
|
|
216
|
+
const reqUrl = new URL(req.url);
|
|
209
217
|
const target = new URL(req.url);
|
|
210
218
|
target.hostname = "localhost";
|
|
211
219
|
target.port = String(APP_PORT);
|
|
212
220
|
|
|
221
|
+
const forwardedHeaders = new Headers(req.headers);
|
|
222
|
+
forwardedHeaders.set("x-forwarded-host", reqUrl.host);
|
|
223
|
+
forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
|
|
224
|
+
|
|
213
225
|
return await fetch(
|
|
214
226
|
new Request(target.toString(), {
|
|
215
227
|
method: req.method,
|
|
216
|
-
headers:
|
|
228
|
+
headers: forwardedHeaders,
|
|
217
229
|
body: req.body,
|
|
218
230
|
redirect: "manual",
|
|
219
231
|
}),
|
package/src/core/errors.ts
CHANGED
|
@@ -29,11 +29,10 @@ export class Redirect {
|
|
|
29
29
|
const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
|
|
30
30
|
|
|
31
31
|
function validateRedirectLocation(location: string, options?: RedirectOptions): void {
|
|
32
|
-
if (options?.allowExternal) return;
|
|
33
|
-
|
|
34
32
|
const trimmed = location.trim();
|
|
35
33
|
|
|
36
|
-
//
|
|
34
|
+
// Dangerous schemes are rejected even when `allowExternal: true` —
|
|
35
|
+
// `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
|
|
37
36
|
if (DANGEROUS_SCHEMES.test(trimmed)) {
|
|
38
37
|
throw new Error(
|
|
39
38
|
`redirect(): dangerous scheme in URL "${location}". ` +
|
|
@@ -41,6 +40,8 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
|
|
|
41
40
|
);
|
|
42
41
|
}
|
|
43
42
|
|
|
43
|
+
if (options?.allowExternal) return;
|
|
44
|
+
|
|
44
45
|
// Reject protocol-relative URLs (//evil.com)
|
|
45
46
|
if (trimmed.startsWith("//")) {
|
|
46
47
|
throw new Error(
|
package/src/core/hooks.ts
CHANGED
|
@@ -34,7 +34,16 @@ export interface Cookies {
|
|
|
34
34
|
export type RequestEvent = {
|
|
35
35
|
request: Request;
|
|
36
36
|
url: URL;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Per-request scratch object for user hooks/load functions.
|
|
39
|
+
*
|
|
40
|
+
* `locals.nonce` is populated by the framework with a fresh per-request
|
|
41
|
+
* cryptographic nonce (base64, 128 bits of entropy) and is safe to embed
|
|
42
|
+
* as `nonce="${event.locals.nonce}"` on user-authored inline scripts when
|
|
43
|
+
* the operator enables a `Content-Security-Policy` via the
|
|
44
|
+
* `CSP_DIRECTIVES` env var.
|
|
45
|
+
*/
|
|
46
|
+
locals: Record<string, any> & { nonce?: string };
|
|
38
47
|
params: Record<string, string>;
|
|
39
48
|
cookies: Cookies;
|
|
40
49
|
};
|
|
@@ -47,6 +56,33 @@ export type LoadEvent = {
|
|
|
47
56
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
48
57
|
parent: () => Promise<Record<string, any>>;
|
|
49
58
|
metadata: Record<string, any> | null;
|
|
59
|
+
/**
|
|
60
|
+
* Declare custom dependency keys for this loader. The client cache
|
|
61
|
+
* will re-run the loader when `invalidate(key)` is called with any
|
|
62
|
+
* of these keys. Keys are arbitrary strings, but conventionally
|
|
63
|
+
* namespaced (e.g. `"app:user"`).
|
|
64
|
+
*/
|
|
65
|
+
depends: (...keys: string[]) => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tracked dependencies captured for a single loader during one run.
|
|
70
|
+
* Shipped to the client so subsequent client-side navigations can
|
|
71
|
+
* decide whether to re-run the loader.
|
|
72
|
+
*/
|
|
73
|
+
export type LoaderDeps = {
|
|
74
|
+
/** `depends(...keys)` declarations. */
|
|
75
|
+
keys: string[];
|
|
76
|
+
/** Absolute URLs passed to the loader's `fetch()`. */
|
|
77
|
+
urls: string[];
|
|
78
|
+
/** Route params the loader read (`params.X`). */
|
|
79
|
+
params: string[];
|
|
80
|
+
/** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`). */
|
|
81
|
+
searchParams: string[];
|
|
82
|
+
/** Cookies the loader read (`cookies.get(X)`). */
|
|
83
|
+
cookies: string[];
|
|
84
|
+
/** True if the loader read `url.pathname`/`url.origin`/`url.hash`/`url.href`. */
|
|
85
|
+
uses_url: boolean;
|
|
50
86
|
};
|
|
51
87
|
|
|
52
88
|
export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
|