okengine 0.4.3 → 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 +3 -1
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +140 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +117 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/fx.mdx +20 -5
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +119 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +12 -12
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +42 -3
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +282 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +253 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/plugins/security-headers.ts +0 -255
|
@@ -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({ 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({ 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
|
|
|
@@ -45,8 +45,8 @@ describe("securityHeaders plugin", () => {
|
|
|
45
45
|
|
|
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
|
-
const app = oke({ name: "sec-off" }).plug(
|
|
49
|
-
|
|
48
|
+
const app = oke({ autoBoot: false, name: "sec-off" }).plug(
|
|
49
|
+
headers({
|
|
50
50
|
originAgentCluster: false,
|
|
51
51
|
dnsPrefetchControl: false,
|
|
52
52
|
downloadOptions: false,
|
|
@@ -65,8 +65,8 @@ describe("securityHeaders plugin", () => {
|
|
|
65
65
|
|
|
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
|
-
const app = oke({ name: "sec-tuned" }).plug(
|
|
69
|
-
|
|
68
|
+
const app = oke({ autoBoot: false, name: "sec-tuned" }).plug(
|
|
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({ 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"));
|
|
@@ -112,8 +112,8 @@ describe("securityHeaders plugin", () => {
|
|
|
112
112
|
|
|
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
|
-
const app = oke({ name: "sec-csp-builder" }).plug(
|
|
116
|
-
|
|
115
|
+
const app = oke({ autoBoot: false, name: "sec-csp-builder" }).plug(
|
|
116
|
+
headers({
|
|
117
117
|
contentSecurityPolicy: {
|
|
118
118
|
directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
|
|
119
119
|
reportOnly: true,
|
|
@@ -132,8 +132,8 @@ describe("securityHeaders plugin", () => {
|
|
|
132
132
|
|
|
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
|
-
const app = oke({ name: "sec-csp-bare" }).plug(
|
|
136
|
-
|
|
135
|
+
const app = oke({ autoBoot: false, name: "sec-csp-bare" }).plug(
|
|
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({ 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
|
|
|
@@ -165,8 +165,8 @@ describe("securityHeaders plugin", () => {
|
|
|
165
165
|
|
|
166
166
|
test("adds CSP only when configured", async () => {
|
|
167
167
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
168
|
-
const app = oke({ name: "sec-csp" }).plug(
|
|
169
|
-
|
|
168
|
+
const app = oke({ autoBoot: false, name: "sec-csp" }).plug(
|
|
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({ 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({ 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
|
|
|
@@ -194,8 +194,8 @@ describe("securityHeaders plugin", () => {
|
|
|
194
194
|
|
|
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
|
-
const app = oke({ name: "sec-hsts-tuned" }).plug(
|
|
198
|
-
|
|
197
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-tuned" }).plug(
|
|
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"));
|
|
@@ -207,8 +207,8 @@ describe("securityHeaders plugin", () => {
|
|
|
207
207
|
|
|
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
|
-
const app = oke({ name: "sec-extra" }).plug(
|
|
211
|
-
|
|
210
|
+
const app = oke({ autoBoot: false, name: "sec-extra" }).plug(
|
|
211
|
+
headers({
|
|
212
212
|
permissionsPolicy: "camera=(), microphone=()",
|
|
213
213
|
crossOriginOpenerPolicy: "same-origin",
|
|
214
214
|
crossOriginResourcePolicy: "same-site",
|
|
@@ -224,7 +224,7 @@ describe("securityHeaders plugin", () => {
|
|
|
224
224
|
|
|
225
225
|
test("an explicit app-set value wins by default", async () => {
|
|
226
226
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
227
|
-
const app = oke({ name: "sec-keep" });
|
|
227
|
+
const app = oke({ autoBoot: false, name: "sec-keep" });
|
|
228
228
|
app.hook("onResponse", (ctx) => {
|
|
229
229
|
if (!ctx.response) return;
|
|
230
230
|
const headers = new Headers(ctx.response.headers);
|
|
@@ -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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* `.plug()`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export { anonymous, type AnonymousPluginOptions } from "./anonymous.ts";
|
|
7
8
|
export { compression, type CompressionOptions } from "./compression.ts";
|
|
8
9
|
export {
|
|
9
10
|
configSource,
|
|
@@ -15,12 +16,50 @@ export {
|
|
|
15
16
|
} from "./config-source.ts";
|
|
16
17
|
export { cors, type CorsOptions } from "./cors.ts";
|
|
17
18
|
export { csrf, type CsrfOptions } from "./csrf.ts";
|
|
19
|
+
export { emailOtp, type EmailOtpOptions } from "./email-otp.ts";
|
|
18
20
|
export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
|
|
21
|
+
export { magicLink, type MagicLinkOptions } from "./magic-link.ts";
|
|
19
22
|
export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
|
|
23
|
+
export {
|
|
24
|
+
createPasskeyStore,
|
|
25
|
+
passkey,
|
|
26
|
+
type PasskeyCredential,
|
|
27
|
+
type PasskeyOptions,
|
|
28
|
+
type PasskeyStore,
|
|
29
|
+
} from "./passkey.ts";
|
|
30
|
+
export {
|
|
31
|
+
b64urlDecode,
|
|
32
|
+
b64urlEncode,
|
|
33
|
+
buildAuthenticatorData,
|
|
34
|
+
signWebAuthnAssertion,
|
|
35
|
+
verifyWebAuthnCeremony,
|
|
36
|
+
type WebAuthnVerifyOptions,
|
|
37
|
+
} from "./passkey-webauthn.ts";
|
|
38
|
+
export {
|
|
39
|
+
createPhoneStore,
|
|
40
|
+
phoneNumber,
|
|
41
|
+
type PhoneNumberOptions,
|
|
42
|
+
type PhoneStore,
|
|
43
|
+
} from "./phone-number.ts";
|
|
20
44
|
export {
|
|
21
45
|
defaultCspDirectives,
|
|
22
|
-
|
|
46
|
+
headers,
|
|
23
47
|
type CspOptions,
|
|
48
|
+
type HeadersOptions,
|
|
24
49
|
type HstsOptions,
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
} from "./headers.ts";
|
|
51
|
+
export {
|
|
52
|
+
createTwoFactorStore,
|
|
53
|
+
twoFactor,
|
|
54
|
+
verifyTotp,
|
|
55
|
+
type TwoFactorOptions,
|
|
56
|
+
type TwoFactorRow,
|
|
57
|
+
type TwoFactorStore,
|
|
58
|
+
} from "./two-factor.ts";
|
|
59
|
+
export {
|
|
60
|
+
createUsernameStore,
|
|
61
|
+
username,
|
|
62
|
+
type UsernamePluginOptions,
|
|
63
|
+
type UsernameRow,
|
|
64
|
+
type UsernameStore,
|
|
65
|
+
} from "./username.ts";
|
|
@@ -26,7 +26,7 @@ function get(ip?: string): Request {
|
|
|
26
26
|
describe("ipAllowlist plugin", () => {
|
|
27
27
|
test("allow list: listed IPs pass, others get 403 Forbidden", async () => {
|
|
28
28
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
29
|
-
const app = oke({ name: "ips" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
29
|
+
const app = oke({ autoBoot: false, name: "ips" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
30
30
|
|
|
31
31
|
const ok = await app.fetch(get("203.0.113.7"));
|
|
32
32
|
expect(ok.status).toBe(200);
|
|
@@ -40,7 +40,9 @@ describe("ipAllowlist plugin", () => {
|
|
|
40
40
|
|
|
41
41
|
test("deny list: blocked IPs get 403 ip_denied, others pass", async () => {
|
|
42
42
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
43
|
-
const app = oke({ name: "ips-deny" }).plug(
|
|
43
|
+
const app = oke({ autoBoot: false, name: "ips-deny" }).plug(
|
|
44
|
+
ipAllowlist({ deny: ["198.51.100.9"] }),
|
|
45
|
+
);
|
|
44
46
|
|
|
45
47
|
const blocked = await app.fetch(get("198.51.100.9"));
|
|
46
48
|
expect(blocked.status).toBe(403);
|
|
@@ -53,7 +55,7 @@ describe("ipAllowlist plugin", () => {
|
|
|
53
55
|
|
|
54
56
|
test("deny wins over allow on overlap", async () => {
|
|
55
57
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
56
|
-
const app = oke({ name: "ips-both" }).plug(
|
|
58
|
+
const app = oke({ autoBoot: false, name: "ips-both" }).plug(
|
|
57
59
|
ipAllowlist({ allow: ["203.0.113.7"], deny: ["203.0.113.7"] }),
|
|
58
60
|
);
|
|
59
61
|
|
|
@@ -63,7 +65,9 @@ describe("ipAllowlist plugin", () => {
|
|
|
63
65
|
|
|
64
66
|
test("XFF last hop is the client; a spoofed first hop cannot bypass allow", async () => {
|
|
65
67
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
66
|
-
const app = oke({ name: "ips-xff" }).plug(
|
|
68
|
+
const app = oke({ autoBoot: false, name: "ips-xff" }).plug(
|
|
69
|
+
ipAllowlist({ allow: ["203.0.113.7"] }),
|
|
70
|
+
);
|
|
67
71
|
|
|
68
72
|
// Attacker-controlled first hop + trusted proxy appended the real client (last).
|
|
69
73
|
const spoofedFirst = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
@@ -75,7 +79,9 @@ describe("ipAllowlist plugin", () => {
|
|
|
75
79
|
|
|
76
80
|
test("spoofed first hop cannot bypass a deny rule", async () => {
|
|
77
81
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
78
|
-
const app = oke({ name: "ips-xff-deny" }).plug(
|
|
82
|
+
const app = oke({ autoBoot: false, name: "ips-xff-deny" }).plug(
|
|
83
|
+
ipAllowlist({ deny: ["198.51.100.9"] }),
|
|
84
|
+
);
|
|
79
85
|
|
|
80
86
|
// Spoof a non-denied IP first; proxy appended the real (denied) client last.
|
|
81
87
|
const spoofed = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
@@ -88,7 +94,7 @@ describe("ipAllowlist plugin", () => {
|
|
|
88
94
|
test("trustedProxyDepth selects the hop behind N trusted proxies", async () => {
|
|
89
95
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
90
96
|
// Chain: spoofed, real-client, cdn-egress — depth 2 skips the nearest proxy hop.
|
|
91
|
-
const app = oke({ name: "ips-depth" }).plug(
|
|
97
|
+
const app = oke({ autoBoot: false, name: "ips-depth" }).plug(
|
|
92
98
|
ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 2 }),
|
|
93
99
|
);
|
|
94
100
|
|
|
@@ -108,7 +114,9 @@ describe("ipAllowlist plugin", () => {
|
|
|
108
114
|
|
|
109
115
|
test("missing header: denied when allow is set, permitted for deny-only", async () => {
|
|
110
116
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
111
|
-
const strict = oke({ name: "ips-missing-allow" }).plug(
|
|
117
|
+
const strict = oke({ autoBoot: false, name: "ips-missing-allow" }).plug(
|
|
118
|
+
ipAllowlist({ allow: ["203.0.113.7"] }),
|
|
119
|
+
);
|
|
112
120
|
|
|
113
121
|
const denied = await strict.fetch(get());
|
|
114
122
|
expect(denied.status).toBe(403);
|
|
@@ -117,14 +125,16 @@ describe("ipAllowlist plugin", () => {
|
|
|
117
125
|
resetFlowSeq();
|
|
118
126
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
119
127
|
|
|
120
|
-
const lax = oke({ name: "ips-missing-deny" }).plug(
|
|
128
|
+
const lax = oke({ autoBoot: false, name: "ips-missing-deny" }).plug(
|
|
129
|
+
ipAllowlist({ deny: ["198.51.100.9"] }),
|
|
130
|
+
);
|
|
121
131
|
const ok = await lax.fetch(get());
|
|
122
132
|
expect(ok.status).toBe(200);
|
|
123
133
|
});
|
|
124
134
|
|
|
125
135
|
test("custom header name is honored", async () => {
|
|
126
136
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
127
|
-
const app = oke({ name: "ips-custom" }).plug(
|
|
137
|
+
const app = oke({ autoBoot: false, name: "ips-custom" }).plug(
|
|
128
138
|
ipAllowlist({ allow: ["203.0.113.7"], header: "x-real-ip" }),
|
|
129
139
|
);
|
|
130
140
|
|