okengine 0.5.0 → 0.5.1
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/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +11 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/security-headers.ts +0 -255
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
withConfigTable,
|
|
12
12
|
type ConfigSource,
|
|
13
13
|
} from "./config-source.ts";
|
|
14
|
-
import { appendVary } from "./headers.ts";
|
|
14
|
+
import { appendVary } from "./response-headers.ts";
|
|
15
15
|
|
|
16
16
|
/** Options for {@link compression}. */
|
|
17
17
|
export interface CompressionOptions {
|
|
@@ -19,7 +19,7 @@ import { createPluginRegistry } from "../kernel/registry.ts";
|
|
|
19
19
|
import { http } from "../kernel/triggers.ts";
|
|
20
20
|
import { configSource, resolvePluginOptions } from "./config-source.ts";
|
|
21
21
|
import { maintenanceMode } from "./maintenance-mode.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { headers } from "./headers.ts";
|
|
23
23
|
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
resetBindings();
|
|
@@ -74,7 +74,7 @@ describe("configSource — box semantics", () => {
|
|
|
74
74
|
};
|
|
75
75
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
76
76
|
|
|
77
|
-
const table = defineTable("
|
|
77
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
78
78
|
const sql = fx.store(db) as SqlStoreHandle;
|
|
79
79
|
await sql.ensureTable(table);
|
|
80
80
|
await sql
|
|
@@ -83,7 +83,7 @@ describe("configSource — box semantics", () => {
|
|
|
83
83
|
.execute();
|
|
84
84
|
|
|
85
85
|
const source = configSource({
|
|
86
|
-
plugin: "
|
|
86
|
+
plugin: "headers",
|
|
87
87
|
code: { hsts: false },
|
|
88
88
|
db: { store: db },
|
|
89
89
|
kv: cfg,
|
|
@@ -110,11 +110,11 @@ describe("configSource — box semantics", () => {
|
|
|
110
110
|
|
|
111
111
|
const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
|
|
112
112
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
113
|
-
const table = defineTable("
|
|
113
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
114
114
|
await (fx.store(db) as SqlStoreHandle).ensureTable(table);
|
|
115
115
|
|
|
116
116
|
const source = configSource({
|
|
117
|
-
plugin: "
|
|
117
|
+
plugin: "headers",
|
|
118
118
|
code: { frameOptions: "DENY" as const },
|
|
119
119
|
db: { store: db },
|
|
120
120
|
});
|
|
@@ -132,13 +132,13 @@ describe("configSource — box semantics", () => {
|
|
|
132
132
|
|
|
133
133
|
const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
|
|
134
134
|
const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
|
|
135
|
-
const table = defineTable("
|
|
135
|
+
const table = defineTable("headers_config", { key: true, value: true });
|
|
136
136
|
const sql = fx.store(db) as SqlStoreHandle;
|
|
137
137
|
await sql.ensureTable(table);
|
|
138
138
|
await sql.insert(table).values({ key: "config", value: "{not json" }).execute();
|
|
139
139
|
|
|
140
140
|
const source = configSource({
|
|
141
|
-
plugin: "
|
|
141
|
+
plugin: "headers",
|
|
142
142
|
code: {},
|
|
143
143
|
db: { store: db },
|
|
144
144
|
});
|
|
@@ -171,17 +171,17 @@ describe("configSource — plugin wiring", () => {
|
|
|
171
171
|
test("DB-backed sources contribute their config table; static options do not", () => {
|
|
172
172
|
const db = store.sql("cfgdb");
|
|
173
173
|
const source = configSource({
|
|
174
|
-
plugin: "
|
|
174
|
+
plugin: "headers",
|
|
175
175
|
code: {},
|
|
176
176
|
db: { store: db },
|
|
177
177
|
});
|
|
178
178
|
|
|
179
179
|
const registry = createPluginRegistry();
|
|
180
|
-
const registration = registry.plug(
|
|
181
|
-
expect(registration?.tables.map((t) => t.name)).toEqual(["
|
|
180
|
+
const registration = registry.plug(headers(source), { kind: "app" });
|
|
181
|
+
expect(registration?.tables.map((t) => t.name)).toEqual(["headers_config"]);
|
|
182
182
|
|
|
183
183
|
const staticRegistry = createPluginRegistry();
|
|
184
|
-
const staticRegistration = staticRegistry.plug(
|
|
184
|
+
const staticRegistration = staticRegistry.plug(headers({}), { kind: "app" });
|
|
185
185
|
expect(staticRegistration?.tables).toEqual([]);
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -35,8 +35,8 @@ export interface ConfigSourceDb {
|
|
|
35
35
|
/** Options for {@link configSource}. */
|
|
36
36
|
export interface ConfigSourceOptions<T extends object> {
|
|
37
37
|
/**
|
|
38
|
-
* Plugin id this config belongs to (e.g. `"
|
|
39
|
-
*
|
|
38
|
+
* Plugin id this config belongs to (e.g. `"headers"`) — derives the DB
|
|
39
|
+
* row key, the KV key, and the default table name.
|
|
40
40
|
*/
|
|
41
41
|
readonly plugin: string;
|
|
42
42
|
/**
|
package/src/plugins/cors.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
isConfigSource,
|
|
14
14
|
type ConfigSource,
|
|
15
15
|
} from "./config-source.ts";
|
|
16
|
-
import { appendVary, withHeaders } from "./headers.ts";
|
|
16
|
+
import { appendVary, withHeaders } from "./response-headers.ts";
|
|
17
17
|
|
|
18
18
|
/** Options for {@link cors}. */
|
|
19
19
|
export interface CorsOptions {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `headers` plugin — defaults on success, presence on failure,
|
|
3
3
|
* and app-wins override semantics through the real pipeline.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -9,17 +9,17 @@ import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
|
9
9
|
import { fail } from "../kernel/index.ts";
|
|
10
10
|
import { on, resetBindings } from "../kernel/on.ts";
|
|
11
11
|
import { http } from "../kernel/triggers.ts";
|
|
12
|
-
import { defaultCspDirectives,
|
|
12
|
+
import { defaultCspDirectives, headers } from "./headers.ts";
|
|
13
13
|
|
|
14
14
|
beforeEach(() => {
|
|
15
15
|
resetBindings();
|
|
16
16
|
resetFlowSeq();
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
describe("
|
|
19
|
+
describe("headers plugin", () => {
|
|
20
20
|
test("sets the default trio on a successful response", async () => {
|
|
21
21
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
22
|
-
const app = oke({ autoBoot: false, name: "sec" }).plug(
|
|
22
|
+
const app = oke({ autoBoot: false, name: "sec" }).plug(headers());
|
|
23
23
|
|
|
24
24
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ describe("securityHeaders plugin", () => {
|
|
|
32
32
|
|
|
33
33
|
test("helmet-parity defaults: agent cluster, dns prefetch, download, cross-domain, xss filter", async () => {
|
|
34
34
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
35
|
-
const app = oke({ autoBoot: false, name: "sec-parity" }).plug(
|
|
35
|
+
const app = oke({ autoBoot: false, name: "sec-parity" }).plug(headers());
|
|
36
36
|
|
|
37
37
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
38
38
|
|
|
@@ -46,7 +46,7 @@ describe("securityHeaders plugin", () => {
|
|
|
46
46
|
test("each parity default can be switched off", async () => {
|
|
47
47
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
48
48
|
const app = oke({ autoBoot: false, name: "sec-off" }).plug(
|
|
49
|
-
|
|
49
|
+
headers({
|
|
50
50
|
originAgentCluster: false,
|
|
51
51
|
dnsPrefetchControl: false,
|
|
52
52
|
downloadOptions: false,
|
|
@@ -66,7 +66,7 @@ describe("securityHeaders plugin", () => {
|
|
|
66
66
|
test("dnsPrefetchControl allow, custom cross-domain policy, COEP stamps when configured", async () => {
|
|
67
67
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
68
68
|
const app = oke({ autoBoot: false, name: "sec-tuned" }).plug(
|
|
69
|
-
|
|
69
|
+
headers({
|
|
70
70
|
dnsPrefetchControl: { allow: true },
|
|
71
71
|
permittedCrossDomainPolicies: "by-content-type",
|
|
72
72
|
crossOriginEmbedderPolicy: "require-corp",
|
|
@@ -81,7 +81,7 @@ describe("securityHeaders plugin", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test("x-powered-by is removed by default; a string sets a decoy; false keeps the app's", async () => {
|
|
84
|
-
const poweredByApp = (options: Parameters<typeof
|
|
84
|
+
const poweredByApp = (options: Parameters<typeof headers>[0], name: string) => {
|
|
85
85
|
on(
|
|
86
86
|
http.get("/x"),
|
|
87
87
|
flow({
|
|
@@ -92,7 +92,7 @@ describe("securityHeaders plugin", () => {
|
|
|
92
92
|
}),
|
|
93
93
|
}),
|
|
94
94
|
);
|
|
95
|
-
return oke({ autoBoot: false, name }).plug(
|
|
95
|
+
return oke({ autoBoot: false, name }).plug(headers(options));
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
const removed = await poweredByApp({}, "sec-pb-off").fetch(new Request("http://localhost/x"));
|
|
@@ -113,7 +113,7 @@ describe("securityHeaders plugin", () => {
|
|
|
113
113
|
test("structured CSP merges over helmet's defaults, camelCase keys, report-only mode", async () => {
|
|
114
114
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
115
115
|
const app = oke({ autoBoot: false, name: "sec-csp-builder" }).plug(
|
|
116
|
-
|
|
116
|
+
headers({
|
|
117
117
|
contentSecurityPolicy: {
|
|
118
118
|
directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
|
|
119
119
|
reportOnly: true,
|
|
@@ -133,7 +133,7 @@ describe("securityHeaders plugin", () => {
|
|
|
133
133
|
test("structured CSP with useDefaults: false emits exactly the given directives", async () => {
|
|
134
134
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
135
135
|
const app = oke({ autoBoot: false, name: "sec-csp-bare" }).plug(
|
|
136
|
-
|
|
136
|
+
headers({
|
|
137
137
|
contentSecurityPolicy: { useDefaults: false, directives: { "default-src": ["'none'"] } },
|
|
138
138
|
}),
|
|
139
139
|
);
|
|
@@ -155,7 +155,7 @@ describe("securityHeaders plugin", () => {
|
|
|
155
155
|
fail("Forbidden", {}),
|
|
156
156
|
),
|
|
157
157
|
);
|
|
158
|
-
const app = oke({ autoBoot: false, name: "sec-fail" }).plug(
|
|
158
|
+
const app = oke({ autoBoot: false, name: "sec-fail" }).plug(headers());
|
|
159
159
|
|
|
160
160
|
const res = await app.fetch(new Request("http://localhost/deny"));
|
|
161
161
|
|
|
@@ -166,7 +166,7 @@ describe("securityHeaders plugin", () => {
|
|
|
166
166
|
test("adds CSP only when configured", async () => {
|
|
167
167
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
168
168
|
const app = oke({ autoBoot: false, name: "sec-csp" }).plug(
|
|
169
|
-
|
|
169
|
+
headers({ contentSecurityPolicy: "default-src 'self'" }),
|
|
170
170
|
);
|
|
171
171
|
|
|
172
172
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
@@ -176,7 +176,7 @@ describe("securityHeaders plugin", () => {
|
|
|
176
176
|
|
|
177
177
|
test("HSTS is off by default", async () => {
|
|
178
178
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
179
|
-
const app = oke({ autoBoot: false, name: "sec-hsts-off" }).plug(
|
|
179
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-off" }).plug(headers());
|
|
180
180
|
|
|
181
181
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
182
182
|
|
|
@@ -185,7 +185,7 @@ describe("securityHeaders plugin", () => {
|
|
|
185
185
|
|
|
186
186
|
test("hsts: true stamps a one-year max-age", async () => {
|
|
187
187
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
188
|
-
const app = oke({ autoBoot: false, name: "sec-hsts-on" }).plug(
|
|
188
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-on" }).plug(headers({ hsts: true }));
|
|
189
189
|
|
|
190
190
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
191
191
|
|
|
@@ -195,7 +195,7 @@ describe("securityHeaders plugin", () => {
|
|
|
195
195
|
test("hsts object tunes max-age, subdomains, preload", async () => {
|
|
196
196
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
197
197
|
const app = oke({ autoBoot: false, name: "sec-hsts-tuned" }).plug(
|
|
198
|
-
|
|
198
|
+
headers({ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true } }),
|
|
199
199
|
);
|
|
200
200
|
|
|
201
201
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
@@ -208,7 +208,7 @@ describe("securityHeaders plugin", () => {
|
|
|
208
208
|
test("permissions-policy and cross-origin policies stamp when configured", async () => {
|
|
209
209
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
210
210
|
const app = oke({ autoBoot: false, name: "sec-extra" }).plug(
|
|
211
|
-
|
|
211
|
+
headers({
|
|
212
212
|
permissionsPolicy: "camera=(), microphone=()",
|
|
213
213
|
crossOriginOpenerPolicy: "same-origin",
|
|
214
214
|
crossOriginResourcePolicy: "same-site",
|
|
@@ -234,7 +234,7 @@ describe("securityHeaders plugin", () => {
|
|
|
234
234
|
headers,
|
|
235
235
|
});
|
|
236
236
|
});
|
|
237
|
-
app.plug(
|
|
237
|
+
app.plug(headers());
|
|
238
238
|
|
|
239
239
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
240
240
|
|
package/src/plugins/headers.ts
CHANGED
|
@@ -1,54 +1,253 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Official `headers` plugin — the complete secure-headers set on every HTTP
|
|
3
|
+
* response (helmet parity, API-first defaults). Uses only the public plugin
|
|
4
|
+
* API (unified-theory §14).
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
7
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
8
|
+
import {
|
|
9
|
+
isConfigSource,
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
type ConfigSource,
|
|
14
|
+
} from "./config-source.ts";
|
|
15
|
+
import { setUnlessPresent, withHeaders } from "./response-headers.ts";
|
|
16
|
+
|
|
17
|
+
/** HSTS value options (see {@link HeadersOptions.hsts}). */
|
|
18
|
+
export interface HstsOptions {
|
|
19
|
+
/** `max-age` in seconds. Default `31536000` (one year). */
|
|
20
|
+
readonly maxAge?: number;
|
|
21
|
+
/** Append `includeSubDomains`. Default `false`. */
|
|
22
|
+
readonly includeSubDomains?: boolean;
|
|
23
|
+
/** Append `preload`. Only meaningful with subdomains + a ≥1-year max-age. */
|
|
24
|
+
readonly preload?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Structured CSP (see {@link HeadersOptions.contentSecurityPolicy}). */
|
|
28
|
+
export interface CspOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Directive map — kebab-case (`script-src`) or camelCase (`scriptSrc`)
|
|
31
|
+
* keys, values as string arrays. An empty array emits a bare directive
|
|
32
|
+
* (e.g. `upgrade-insecure-requests`).
|
|
33
|
+
*/
|
|
34
|
+
readonly directives: Readonly<Record<string, readonly string[]>>;
|
|
35
|
+
/** Merge over {@link defaultCspDirectives}. Default `true`. */
|
|
36
|
+
readonly useDefaults?: boolean;
|
|
37
|
+
/** Emit as `Content-Security-Policy-Report-Only`. Default `false`. */
|
|
38
|
+
readonly reportOnly?: boolean;
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* @param headers - Mutable headers
|
|
26
|
-
* @param name - Header name
|
|
27
|
-
* @param value - Header value
|
|
28
|
-
* @param override - Replace an app-set value
|
|
42
|
+
* Helmet's default CSP — the baseline `directives` merge over unless
|
|
43
|
+
* `useDefaults: false`.
|
|
29
44
|
*/
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
export const defaultCspDirectives: Readonly<Record<string, readonly string[]>> = {
|
|
46
|
+
"default-src": ["'self'"],
|
|
47
|
+
"base-uri": ["'self'"],
|
|
48
|
+
"font-src": ["'self'", "https:", "data:"],
|
|
49
|
+
"form-action": ["'self'"],
|
|
50
|
+
"frame-ancestors": ["'self'"],
|
|
51
|
+
"img-src": ["'self'", "data:"],
|
|
52
|
+
"object-src": ["'none'"],
|
|
53
|
+
"script-src": ["'self'"],
|
|
54
|
+
"script-src-attr": ["'none'"],
|
|
55
|
+
"style-src": ["'self'", "https:", "'unsafe-inline'"],
|
|
56
|
+
"upgrade-insecure-requests": [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Options for {@link headers}. */
|
|
60
|
+
export interface HeadersOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Content-Security-Policy — a raw header string, or a structured
|
|
63
|
+
* {@link CspOptions} (directive builder over helmet's defaults, optional
|
|
64
|
+
* report-only mode). Omitted unless provided.
|
|
65
|
+
*/
|
|
66
|
+
readonly contentSecurityPolicy?: string | CspOptions;
|
|
67
|
+
/** X-Frame-Options value. Default `"DENY"`. */
|
|
68
|
+
readonly frameOptions?: "DENY" | "SAMEORIGIN";
|
|
69
|
+
/** Referrer-Policy value. Default `"no-referrer"`. */
|
|
70
|
+
readonly referrerPolicy?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Strict-Transport-Security. `true` → one-year `max-age`; pass an object
|
|
73
|
+
* to tune. Default off — HSTS is sticky, and local dev is plain HTTP, so
|
|
74
|
+
* enable it deliberately for HTTPS deployments.
|
|
75
|
+
*/
|
|
76
|
+
readonly hsts?: boolean | HstsOptions;
|
|
77
|
+
/** Permissions-Policy value (e.g. `"camera=(), microphone=()"`). Omitted unless provided. */
|
|
78
|
+
readonly permissionsPolicy?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Cross-Origin-Opener-Policy value. Omitted unless provided — opt-in
|
|
81
|
+
* because OKE serves APIs, where cross-origin clients are legitimate
|
|
82
|
+
* (helmet targets web pages and defaults it on).
|
|
83
|
+
*/
|
|
84
|
+
readonly crossOriginOpenerPolicy?: "same-origin" | "same-origin-allow-popups" | "unsafe-none";
|
|
85
|
+
/** Cross-Origin-Resource-Policy value. Omitted unless provided (same API rationale). */
|
|
86
|
+
readonly crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin";
|
|
87
|
+
/** Cross-Origin-Embedder-Policy value. Omitted unless provided (helmet also defaults it off). */
|
|
88
|
+
readonly crossOriginEmbedderPolicy?: "require-corp" | "credentialless";
|
|
89
|
+
/** Origin-Agent-Cluster header. Default `true` → `?1`. */
|
|
90
|
+
readonly originAgentCluster?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* X-DNS-Prefetch-Control. Default `true` → `off` (privacy-preserving);
|
|
93
|
+
* `{ allow: true }` → `on`; `false` omits the header.
|
|
94
|
+
*/
|
|
95
|
+
readonly dnsPrefetchControl?: boolean | { readonly allow: boolean };
|
|
96
|
+
/** X-Download-Options: noopen (legacy IE8 mitigation, helmet parity). Default `true`. */
|
|
97
|
+
readonly downloadOptions?: boolean;
|
|
98
|
+
/** X-Permitted-Cross-Domain-Policies value. Default `"none"`. */
|
|
99
|
+
readonly permittedCrossDomainPolicies?: "none" | "master-only" | "by-content-type" | "all";
|
|
100
|
+
/**
|
|
101
|
+
* X-Powered-By handling. Default `true` → remove the header (it leaks
|
|
102
|
+
* framework fingerprints). A string sets a custom value; `false` keeps it.
|
|
103
|
+
*/
|
|
104
|
+
readonly poweredBy?: boolean | string;
|
|
105
|
+
/**
|
|
106
|
+
* X-XSS-Protection: 0 — disables the legacy, buggy browser XSS auditor
|
|
107
|
+
* (helmet parity; modern defense is CSP). Default `true`.
|
|
108
|
+
*/
|
|
109
|
+
readonly xssProtection?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Replace headers the app already set. Default `false` — an explicit
|
|
112
|
+
* app-level value always wins.
|
|
113
|
+
*/
|
|
114
|
+
readonly override?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render the Strict-Transport-Security header value. */
|
|
118
|
+
function hstsValue(hsts: boolean | HstsOptions): string {
|
|
119
|
+
const opts: HstsOptions = typeof hsts === "object" ? hsts : {};
|
|
120
|
+
let value = `max-age=${opts.maxAge ?? 31536000}`;
|
|
121
|
+
if (opts.includeSubDomains) value += "; includeSubDomains";
|
|
122
|
+
if (opts.preload) value += "; preload";
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** camelCase directive keys → kebab-case (`scriptSrc` → `script-src`). */
|
|
127
|
+
function directiveName(name: string): string {
|
|
128
|
+
return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Build a CSP header value from a directive map. */
|
|
132
|
+
function buildCsp(directives: Readonly<Record<string, readonly string[]>>): string {
|
|
133
|
+
return Object.entries(directives)
|
|
134
|
+
.map(([name, values]) => {
|
|
135
|
+
const kebab = directiveName(name);
|
|
136
|
+
return values.length === 0 ? kebab : `${kebab} ${values.join(" ")}`;
|
|
137
|
+
})
|
|
138
|
+
.join("; ");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Resolve the CSP option into header name + value. */
|
|
142
|
+
function cspEntry(option: string | CspOptions): { readonly name: string; readonly value: string } {
|
|
143
|
+
if (typeof option === "string") return { name: "content-security-policy", value: option };
|
|
144
|
+
const useDefaults = option.useDefaults ?? true;
|
|
145
|
+
const merged: Record<string, readonly string[]> = useDefaults
|
|
146
|
+
? { ...defaultCspDirectives, ...option.directives }
|
|
147
|
+
: { ...option.directives };
|
|
148
|
+
return {
|
|
149
|
+
name:
|
|
150
|
+
(option.reportOnly ?? false)
|
|
151
|
+
? "content-security-policy-report-only"
|
|
152
|
+
: "content-security-policy",
|
|
153
|
+
value: buildCsp(merged),
|
|
154
|
+
};
|
|
38
155
|
}
|
|
39
156
|
|
|
40
157
|
/**
|
|
41
|
-
*
|
|
158
|
+
* Apply the complete secure-headers set to every HTTP response, including
|
|
159
|
+
* failures (runs at `onResponse`, which fires after `onError` too). Covers
|
|
160
|
+
* every helmet.js middleware; API-first deviations (opt-in HSTS, COOP,
|
|
161
|
+
* CORP) are documented per option.
|
|
162
|
+
*
|
|
163
|
+
* Accepts static options or a {@link ConfigSource} for DB-driven config
|
|
164
|
+
* (e.g. flip `hsts` from the database without a redeploy).
|
|
42
165
|
*
|
|
43
|
-
* @param
|
|
44
|
-
* @param token - Vary token (e.g. `"origin"`)
|
|
166
|
+
* @param options - Header values, or a config source
|
|
45
167
|
*/
|
|
46
|
-
export function
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
168
|
+
export function headers(options: HeadersOptions | ConfigSource<HeadersOptions> = {}): PluginDef {
|
|
169
|
+
const def = plugin("headers", {
|
|
170
|
+
version: "0.1.0",
|
|
171
|
+
config: pluginConfigSnapshot(options),
|
|
172
|
+
}).hook("onResponse", (ctx) => {
|
|
173
|
+
if (!ctx.response) return;
|
|
174
|
+
const resolved = resolvePluginOptions(options);
|
|
175
|
+
const override = resolved.override ?? false;
|
|
176
|
+
|
|
177
|
+
ctx.response = withHeaders(ctx.response, (headers) => {
|
|
178
|
+
setUnlessPresent(headers, "x-content-type-options", "nosniff", override);
|
|
179
|
+
setUnlessPresent(headers, "x-frame-options", resolved.frameOptions ?? "DENY", override);
|
|
180
|
+
setUnlessPresent(
|
|
181
|
+
headers,
|
|
182
|
+
"referrer-policy",
|
|
183
|
+
resolved.referrerPolicy ?? "no-referrer",
|
|
184
|
+
override,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (resolved.originAgentCluster ?? true) {
|
|
188
|
+
setUnlessPresent(headers, "origin-agent-cluster", "?1", override);
|
|
189
|
+
}
|
|
190
|
+
const dns = resolved.dnsPrefetchControl ?? true;
|
|
191
|
+
if (dns !== false) {
|
|
192
|
+
const allow = typeof dns === "object" ? dns.allow : false;
|
|
193
|
+
setUnlessPresent(headers, "x-dns-prefetch-control", allow ? "on" : "off", override);
|
|
194
|
+
}
|
|
195
|
+
if (resolved.downloadOptions ?? true) {
|
|
196
|
+
setUnlessPresent(headers, "x-download-options", "noopen", override);
|
|
197
|
+
}
|
|
198
|
+
setUnlessPresent(
|
|
199
|
+
headers,
|
|
200
|
+
"x-permitted-cross-domain-policies",
|
|
201
|
+
resolved.permittedCrossDomainPolicies ?? "none",
|
|
202
|
+
override,
|
|
203
|
+
);
|
|
204
|
+
if (resolved.xssProtection ?? true) {
|
|
205
|
+
setUnlessPresent(headers, "x-xss-protection", "0", override);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const poweredBy = resolved.poweredBy ?? true;
|
|
209
|
+
if (poweredBy === true) {
|
|
210
|
+
headers.delete("x-powered-by");
|
|
211
|
+
} else if (typeof poweredBy === "string") {
|
|
212
|
+
setUnlessPresent(headers, "x-powered-by", poweredBy, override);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (resolved.contentSecurityPolicy !== undefined) {
|
|
216
|
+
const csp = cspEntry(resolved.contentSecurityPolicy);
|
|
217
|
+
setUnlessPresent(headers, csp.name, csp.value, override);
|
|
218
|
+
}
|
|
219
|
+
if (resolved.hsts !== undefined && resolved.hsts !== false) {
|
|
220
|
+
setUnlessPresent(headers, "strict-transport-security", hstsValue(resolved.hsts), override);
|
|
221
|
+
}
|
|
222
|
+
if (resolved.permissionsPolicy !== undefined) {
|
|
223
|
+
setUnlessPresent(headers, "permissions-policy", resolved.permissionsPolicy, override);
|
|
224
|
+
}
|
|
225
|
+
if (resolved.crossOriginOpenerPolicy !== undefined) {
|
|
226
|
+
setUnlessPresent(
|
|
227
|
+
headers,
|
|
228
|
+
"cross-origin-opener-policy",
|
|
229
|
+
resolved.crossOriginOpenerPolicy,
|
|
230
|
+
override,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (resolved.crossOriginResourcePolicy !== undefined) {
|
|
234
|
+
setUnlessPresent(
|
|
235
|
+
headers,
|
|
236
|
+
"cross-origin-resource-policy",
|
|
237
|
+
resolved.crossOriginResourcePolicy,
|
|
238
|
+
override,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (resolved.crossOriginEmbedderPolicy !== undefined) {
|
|
242
|
+
setUnlessPresent(
|
|
243
|
+
headers,
|
|
244
|
+
"cross-origin-embedder-policy",
|
|
245
|
+
resolved.crossOriginEmbedderPolicy,
|
|
246
|
+
override,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
54
253
|
}
|
package/src/plugins/index.ts
CHANGED
|
@@ -27,6 +27,14 @@ export {
|
|
|
27
27
|
type PasskeyOptions,
|
|
28
28
|
type PasskeyStore,
|
|
29
29
|
} from "./passkey.ts";
|
|
30
|
+
export {
|
|
31
|
+
b64urlDecode,
|
|
32
|
+
b64urlEncode,
|
|
33
|
+
buildAuthenticatorData,
|
|
34
|
+
signWebAuthnAssertion,
|
|
35
|
+
verifyWebAuthnCeremony,
|
|
36
|
+
type WebAuthnVerifyOptions,
|
|
37
|
+
} from "./passkey-webauthn.ts";
|
|
30
38
|
export {
|
|
31
39
|
createPhoneStore,
|
|
32
40
|
phoneNumber,
|
|
@@ -35,11 +43,11 @@ export {
|
|
|
35
43
|
} from "./phone-number.ts";
|
|
36
44
|
export {
|
|
37
45
|
defaultCspDirectives,
|
|
38
|
-
|
|
46
|
+
headers,
|
|
39
47
|
type CspOptions,
|
|
48
|
+
type HeadersOptions,
|
|
40
49
|
type HstsOptions,
|
|
41
|
-
|
|
42
|
-
} from "./security-headers.ts";
|
|
50
|
+
} from "./headers.ts";
|
|
43
51
|
export {
|
|
44
52
|
createTwoFactorStore,
|
|
45
53
|
twoFactor,
|