okengine 0.2.8 → 0.3.4
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/AGENTS.md +17 -15
- package/README.md +59 -31
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +98 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +149 -0
- package/src/cli/openbao-bootstrap.ts +314 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +172 -0
- package/src/plugins/cors.ts +153 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +139 -0
- package/src/plugins/ip-allowlist.ts +129 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `cors` plugin — cross-origin rules at the edge: preflight
|
|
3
|
+
* `OPTIONS` answered even for paths bound to other methods, plus CORS
|
|
4
|
+
* headers on matched responses. Uses the public plugin API (§14) —
|
|
5
|
+
* including the edge contribution for requests that match no flow.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
|
+
import {
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
isConfigSource,
|
|
14
|
+
type ConfigSource,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
import { appendVary, withHeaders } from "./headers.ts";
|
|
17
|
+
|
|
18
|
+
/** Options for {@link cors}. */
|
|
19
|
+
export interface CorsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Origins allowed cross-origin: `"*"` for any, one origin string, or an
|
|
22
|
+
* exact-match list (`"https://app.example.com"`). Default **none** —
|
|
23
|
+
* cross-origin is closed until you open it. Same-origin traffic needs
|
|
24
|
+
* no CORS headers at all.
|
|
25
|
+
*
|
|
26
|
+
* `"*"` cannot be combined with {@link CorsOptions.credentials} —
|
|
27
|
+
* construction throws. List exact origins when cookies/auth headers are
|
|
28
|
+
* involved; there is no "any origin + credentials" shortcut.
|
|
29
|
+
*/
|
|
30
|
+
readonly origin?: "*" | string | readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* Methods answered on preflight. Default covers the verbs `http.*`
|
|
33
|
+
* triggers bind: GET · HEAD · POST · PUT · PATCH · DELETE · OPTIONS.
|
|
34
|
+
*/
|
|
35
|
+
readonly methods?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* `Access-Control-Allow-Headers` on preflight. Default reflects the
|
|
38
|
+
* request's `Access-Control-Request-Headers` back to it.
|
|
39
|
+
*/
|
|
40
|
+
readonly allowedHeaders?: readonly string[];
|
|
41
|
+
/** `Access-Control-Expose-Headers` on actual responses. Omitted unless provided. */
|
|
42
|
+
readonly exposedHeaders?: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* Send `Access-Control-Allow-Credentials: true`. Requires an explicit
|
|
45
|
+
* origin allowlist (string or list) — never `"*"`. Construction throws
|
|
46
|
+
* on `origin: "*" + credentials: true` (that pair would otherwise be
|
|
47
|
+
* silently rewritten into reflected-origin access for every site).
|
|
48
|
+
*/
|
|
49
|
+
readonly credentials?: boolean;
|
|
50
|
+
/** `Access-Control-Max-Age` seconds on preflight. Omitted unless provided. */
|
|
51
|
+
readonly maxAge?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
|
|
55
|
+
|
|
56
|
+
const WILDCARD_CREDENTIALS_ERROR =
|
|
57
|
+
'cors: origin: "*" cannot be combined with credentials: true — list exact origins instead ' +
|
|
58
|
+
'(browsers reject "*" with credentials; reflecting the request origin would grant any site ' +
|
|
59
|
+
"credentialed access)";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reject the dangerous `origin: "*" + credentials: true` pair.
|
|
63
|
+
* Fail loud at construction (and again if runtime config introduces it).
|
|
64
|
+
*/
|
|
65
|
+
function assertSafeCorsOptions(options: CorsOptions): void {
|
|
66
|
+
if (options.origin === "*" && options.credentials === true) {
|
|
67
|
+
throw new Error(WILDCARD_CREDENTIALS_ERROR);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when `origin` is permitted by the configured origin rule. */
|
|
72
|
+
export function originAllowed(origin: string, rule: CorsOptions["origin"]): boolean {
|
|
73
|
+
if (rule === undefined) return false;
|
|
74
|
+
if (rule === "*") return true;
|
|
75
|
+
if (typeof rule === "string") return origin === rule;
|
|
76
|
+
return rule.includes(origin);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The `Access-Control-Allow-Origin` value for a permitted origin. */
|
|
80
|
+
function allowOriginValue(origin: string, options: CorsOptions): string {
|
|
81
|
+
return options.origin === "*" ? "*" : origin;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Is this request a CORS preflight? */
|
|
85
|
+
function isPreflight(request: Request, method: string): boolean {
|
|
86
|
+
return (
|
|
87
|
+
method === "OPTIONS" &&
|
|
88
|
+
request.headers.get("origin") !== null &&
|
|
89
|
+
request.headers.get("access-control-request-method") !== null
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Enforce cross-origin rules. A preflight that matches no flow is answered
|
|
95
|
+
* by the plugin's edge handler; matched responses get their CORS headers
|
|
96
|
+
* at `onResponse`. A denied preflight returns `204` **without** CORS
|
|
97
|
+
* headers — the browser blocks it, which is the correct, quiet failure.
|
|
98
|
+
*
|
|
99
|
+
* Accepts static options or a {@link ConfigSource} for DB-driven origin
|
|
100
|
+
* lists (open an emergency integration origin without a redeploy).
|
|
101
|
+
*
|
|
102
|
+
* @param options - Origin rules and friends, or a config source
|
|
103
|
+
*/
|
|
104
|
+
export function cors(options: CorsOptions | ConfigSource<CorsOptions> = {}): PluginDef {
|
|
105
|
+
assertSafeCorsOptions(pluginConfigSnapshot(options));
|
|
106
|
+
|
|
107
|
+
const def = plugin("cors", { version: "0.0.2", config: pluginConfigSnapshot(options) })
|
|
108
|
+
.edge((request, info) => {
|
|
109
|
+
if (!isPreflight(request, info.method)) return undefined;
|
|
110
|
+
const resolved = resolvePluginOptions(options);
|
|
111
|
+
assertSafeCorsOptions(resolved);
|
|
112
|
+
const origin = request.headers.get("origin")!;
|
|
113
|
+
if (!originAllowed(origin, resolved.origin)) {
|
|
114
|
+
return new Response(null, { status: 204 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const headers = new Headers();
|
|
118
|
+
headers.set("access-control-allow-origin", allowOriginValue(origin, resolved));
|
|
119
|
+
headers.set("access-control-allow-methods", (resolved.methods ?? DEFAULT_METHODS).join(", "));
|
|
120
|
+
const allowed =
|
|
121
|
+
resolved.allowedHeaders?.join(", ") ??
|
|
122
|
+
request.headers.get("access-control-request-headers");
|
|
123
|
+
if (allowed !== null && allowed !== undefined && allowed.length > 0) {
|
|
124
|
+
headers.set("access-control-allow-headers", allowed);
|
|
125
|
+
}
|
|
126
|
+
if (resolved.credentials === true) headers.set("access-control-allow-credentials", "true");
|
|
127
|
+
if (resolved.maxAge !== undefined)
|
|
128
|
+
headers.set("access-control-max-age", String(resolved.maxAge));
|
|
129
|
+
appendVary(headers, "origin");
|
|
130
|
+
appendVary(headers, "access-control-request-method");
|
|
131
|
+
appendVary(headers, "access-control-request-headers");
|
|
132
|
+
return new Response(null, { status: 204, headers });
|
|
133
|
+
})
|
|
134
|
+
.hook("onResponse", (ctx) => {
|
|
135
|
+
if (!ctx.request || !ctx.response) return;
|
|
136
|
+
const origin = ctx.request.headers.get("origin");
|
|
137
|
+
if (origin === null) return;
|
|
138
|
+
const resolved = resolvePluginOptions(options);
|
|
139
|
+
assertSafeCorsOptions(resolved);
|
|
140
|
+
if (!originAllowed(origin, resolved.origin)) return;
|
|
141
|
+
|
|
142
|
+
ctx.response = withHeaders(ctx.response, (headers) => {
|
|
143
|
+
headers.set("access-control-allow-origin", allowOriginValue(origin, resolved));
|
|
144
|
+
if (resolved.credentials === true) headers.set("access-control-allow-credentials", "true");
|
|
145
|
+
if (resolved.exposedHeaders !== undefined && resolved.exposedHeaders.length > 0) {
|
|
146
|
+
headers.set("access-control-expose-headers", resolved.exposedHeaders.join(", "));
|
|
147
|
+
}
|
|
148
|
+
appendVary(headers, "origin");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
153
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `csrf` plugin — fetch-metadata defense with an Origin fallback, through
|
|
3
|
+
* the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { csrf } from "./csrf.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function mutating(headers: Record<string, string> = {}): Request {
|
|
19
|
+
return new Request("http://localhost/transfer", { method: "POST", headers });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appWith(options: Parameters<typeof csrf>[0]) {
|
|
23
|
+
on(http.post("/transfer"), flow({ name: "transfer.create", do: () => ({ moved: true }) }));
|
|
24
|
+
on(http.get("/balance"), flow({ name: "balance.get", do: () => ({ balance: 5 }) }));
|
|
25
|
+
return oke({ name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("csrf plugin", () => {
|
|
29
|
+
test("safe methods always pass", async () => {
|
|
30
|
+
const app = appWith({});
|
|
31
|
+
const res = await app.fetch(
|
|
32
|
+
new Request("http://localhost/balance", {
|
|
33
|
+
headers: { "sec-fetch-site": "cross-site", origin: "https://evil.com" },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("same-origin and none pass; cross-site is denied with typed Forbidden", async () => {
|
|
40
|
+
const app = appWith({});
|
|
41
|
+
|
|
42
|
+
const same = await app.fetch(mutating({ "sec-fetch-site": "same-origin" }));
|
|
43
|
+
expect(same.status).toBe(200);
|
|
44
|
+
|
|
45
|
+
const cross = await app.fetch(
|
|
46
|
+
mutating({ "sec-fetch-site": "cross-site", origin: "https://evil.com" }),
|
|
47
|
+
);
|
|
48
|
+
expect(cross.status).toBe(403);
|
|
49
|
+
const body = (await cross.json()) as { error: { code: string; data: { reason: string } } };
|
|
50
|
+
expect(body.error.code).toBe("Forbidden");
|
|
51
|
+
expect(body.error.data.reason).toBe("csrf");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("same-site passes by default, blocked when allowSameSite: false", async () => {
|
|
55
|
+
const lax = appWith({});
|
|
56
|
+
expect(
|
|
57
|
+
(
|
|
58
|
+
await lax.fetch(
|
|
59
|
+
mutating({ "sec-fetch-site": "same-site", origin: "https://sub.localhost" }),
|
|
60
|
+
)
|
|
61
|
+
).status,
|
|
62
|
+
).toBe(200);
|
|
63
|
+
|
|
64
|
+
const strict = appWith({ allowSameSite: false });
|
|
65
|
+
const res = await strict.fetch(
|
|
66
|
+
mutating({ "sec-fetch-site": "same-site", origin: "https://sub.localhost" }),
|
|
67
|
+
);
|
|
68
|
+
expect(res.status).toBe(403);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Origin fallback: same-origin host passes, allow-listed origins pass, others fail", async () => {
|
|
72
|
+
const app = appWith({ allowOrigins: ["https://admin.example.com"] });
|
|
73
|
+
|
|
74
|
+
const sameHost = await app.fetch(mutating({ origin: "http://localhost" }));
|
|
75
|
+
expect(sameHost.status).toBe(200);
|
|
76
|
+
|
|
77
|
+
const listed = await app.fetch(mutating({ origin: "https://admin.example.com" }));
|
|
78
|
+
expect(listed.status).toBe(200);
|
|
79
|
+
|
|
80
|
+
const evil = await app.fetch(mutating({ origin: "https://evil.com" }));
|
|
81
|
+
expect(evil.status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("headerless mutating requests pass by default (curl, webhooks), fail closed on demand", async () => {
|
|
85
|
+
const lax = appWith({});
|
|
86
|
+
expect((await lax.fetch(mutating())).status).toBe(200);
|
|
87
|
+
|
|
88
|
+
const closed = appWith({ allowNoHeader: false });
|
|
89
|
+
const res = await closed.fetch(mutating());
|
|
90
|
+
expect(res.status).toBe(403);
|
|
91
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
92
|
+
expect(body.error.code).toBe("Forbidden");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cross-site with an allow-listed origin passes (metadata wins are not required)", async () => {
|
|
96
|
+
const app = appWith({ allowOrigins: ["https://admin.example.com"] });
|
|
97
|
+
const res = await app.fetch(
|
|
98
|
+
mutating({ "sec-fetch-site": "cross-site", origin: "https://admin.example.com" }),
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `csrf` plugin — cross-site request forgery defense using fetch
|
|
3
|
+
* metadata (`Sec-Fetch-Site`) with an `Origin` fallback. Stateless: no
|
|
4
|
+
* tokens, no cookies, no session reads. Uses only the public plugin API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fail } from "../kernel/errors.ts";
|
|
8
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
|
+
import {
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
isConfigSource,
|
|
14
|
+
type ConfigSource,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
|
|
17
|
+
/** Options for {@link csrf}. */
|
|
18
|
+
export interface CsrfOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Absolute origins allowed to send mutating requests from another site
|
|
21
|
+
* (e.g. `"https://admin.example.com"` for a separately-hosted console).
|
|
22
|
+
* Same-origin is always allowed.
|
|
23
|
+
*/
|
|
24
|
+
readonly allowOrigins?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Allow `Sec-Fetch-Site: same-site` (subdomains of your site). Default
|
|
27
|
+
* `true`. Set `false` when subdomains host untrusted content.
|
|
28
|
+
*/
|
|
29
|
+
readonly allowSameSite?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Allow mutating requests carrying neither `Sec-Fetch-Site` nor `Origin`
|
|
32
|
+
* — the shape of curl, server-to-server calls, and webhooks, which are
|
|
33
|
+
* not browser CSRF vectors. Default `true`. Set `false` to fail closed
|
|
34
|
+
* when your auth is purely cookie-based.
|
|
35
|
+
*/
|
|
36
|
+
readonly allowNoHeader?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Methods browsers can mutate state with (safe verbs pass untouched). */
|
|
40
|
+
function isMutating(method: string): boolean {
|
|
41
|
+
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Block cross-site state changes at `onAuth`, before gate policies and the
|
|
46
|
+
* flow body. Decision order: safe methods pass → `Sec-Fetch-Site`
|
|
47
|
+
* (`same-origin` / `none` pass; `same-site` per option) → `Origin`
|
|
48
|
+
* fallback (must be same-origin or allow-listed) → headerless clients per
|
|
49
|
+
* `allowNoHeader`. Denials are the gate element's typed `Forbidden`.
|
|
50
|
+
*
|
|
51
|
+
* Accepts static options or a {@link ConfigSource} for DB-driven origin
|
|
52
|
+
* rules.
|
|
53
|
+
*
|
|
54
|
+
* @param options - Origin rules, or a config source
|
|
55
|
+
*/
|
|
56
|
+
export function csrf(options: CsrfOptions | ConfigSource<CsrfOptions> = {}): PluginDef {
|
|
57
|
+
const def = plugin("csrf", { version: "0.0.1", config: pluginConfigSnapshot(options) }).hook(
|
|
58
|
+
"onAuth",
|
|
59
|
+
(ctx) => {
|
|
60
|
+
if (!ctx.request) return;
|
|
61
|
+
const method = ctx.request.method.toUpperCase();
|
|
62
|
+
if (!isMutating(method)) return;
|
|
63
|
+
|
|
64
|
+
const resolved = resolvePluginOptions(options);
|
|
65
|
+
const site = ctx.request.headers.get("sec-fetch-site")?.toLowerCase();
|
|
66
|
+
if (site === "same-origin" || site === "none") return;
|
|
67
|
+
if (site === "same-site" && (resolved.allowSameSite ?? true)) return;
|
|
68
|
+
|
|
69
|
+
const origin = ctx.request.headers.get("origin");
|
|
70
|
+
if (origin !== null) {
|
|
71
|
+
const host = new URL(ctx.request.url).origin;
|
|
72
|
+
if (origin === host || resolved.allowOrigins?.includes(origin) === true) return;
|
|
73
|
+
return fail("Forbidden", {
|
|
74
|
+
reason: "csrf",
|
|
75
|
+
site: site ?? "unknown",
|
|
76
|
+
origin,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (resolved.allowNoHeader ?? true) return;
|
|
81
|
+
return fail("Forbidden", { reason: "csrf", site: site ?? "unknown" });
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared response-header mutation for HTTP middleware plugins.
|
|
3
|
+
* `Headers` are immutable-in-place on a built `Response` — rebuild instead.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rebuild a response with headers mutated by `fn`.
|
|
8
|
+
*
|
|
9
|
+
* @param response - Original response
|
|
10
|
+
* @param fn - Header mutation
|
|
11
|
+
*/
|
|
12
|
+
export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
|
|
13
|
+
const headers = new Headers(response.headers);
|
|
14
|
+
fn(headers);
|
|
15
|
+
return new Response(response.body, {
|
|
16
|
+
status: response.status,
|
|
17
|
+
statusText: response.statusText,
|
|
18
|
+
headers,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set a header only when absent (or when `override` is on).
|
|
24
|
+
*
|
|
25
|
+
* @param headers - Mutable headers
|
|
26
|
+
* @param name - Header name
|
|
27
|
+
* @param value - Header value
|
|
28
|
+
* @param override - Replace an app-set value
|
|
29
|
+
*/
|
|
30
|
+
export function setUnlessPresent(
|
|
31
|
+
headers: Headers,
|
|
32
|
+
name: string,
|
|
33
|
+
value: string,
|
|
34
|
+
override: boolean,
|
|
35
|
+
): void {
|
|
36
|
+
if (!override && headers.has(name)) return;
|
|
37
|
+
headers.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Append a token to `Vary` without duplicating it.
|
|
42
|
+
*
|
|
43
|
+
* @param headers - Mutable headers
|
|
44
|
+
* @param token - Vary token (e.g. `"origin"`)
|
|
45
|
+
*/
|
|
46
|
+
export function appendVary(headers: Headers, token: string): void {
|
|
47
|
+
const vary = headers.get("vary");
|
|
48
|
+
if (vary === null) {
|
|
49
|
+
headers.set("vary", token);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
53
|
+
if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official plugins — first-party extensions built entirely on the public
|
|
3
|
+
* plugin API (`okengine/plugins`). Import what you need; attach with
|
|
4
|
+
* `.plug()`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { compression, type CompressionOptions } from "./compression.ts";
|
|
8
|
+
export {
|
|
9
|
+
configSource,
|
|
10
|
+
isConfigSource,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
type ConfigSource,
|
|
13
|
+
type ConfigSourceDb,
|
|
14
|
+
type ConfigSourceOptions,
|
|
15
|
+
} from "./config-source.ts";
|
|
16
|
+
export { cors, type CorsOptions } from "./cors.ts";
|
|
17
|
+
export { csrf, type CsrfOptions } from "./csrf.ts";
|
|
18
|
+
export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
|
|
19
|
+
export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
|
|
20
|
+
export {
|
|
21
|
+
defaultCspDirectives,
|
|
22
|
+
securityHeaders,
|
|
23
|
+
type CspOptions,
|
|
24
|
+
type HstsOptions,
|
|
25
|
+
type SecurityHeadersOptions,
|
|
26
|
+
} from "./security-headers.ts";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ip-allowlist` plugin — allow/deny semantics, XFF parsing, and
|
|
3
|
+
* missing-header behavior through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { ipAllowlist } from "./ip-allowlist.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Shorthand: GET /x with an optional x-forwarded-for header. */
|
|
19
|
+
function get(ip?: string): Request {
|
|
20
|
+
return new Request(
|
|
21
|
+
"http://localhost/x",
|
|
22
|
+
ip === undefined ? undefined : { headers: { "x-forwarded-for": ip } },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ipAllowlist plugin", () => {
|
|
27
|
+
test("allow list: listed IPs pass, others get 403 Forbidden", async () => {
|
|
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"] }));
|
|
30
|
+
|
|
31
|
+
const ok = await app.fetch(get("203.0.113.7"));
|
|
32
|
+
expect(ok.status).toBe(200);
|
|
33
|
+
|
|
34
|
+
const denied = await app.fetch(get("198.51.100.9"));
|
|
35
|
+
expect(denied.status).toBe(403);
|
|
36
|
+
const body = (await denied.json()) as { error: { code: string; data: { reason: string } } };
|
|
37
|
+
expect(body.error.code).toBe("Forbidden");
|
|
38
|
+
expect(body.error.data.reason).toBe("ip_not_allowed");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("deny list: blocked IPs get 403 ip_denied, others pass", async () => {
|
|
42
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
43
|
+
const app = oke({ name: "ips-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
44
|
+
|
|
45
|
+
const blocked = await app.fetch(get("198.51.100.9"));
|
|
46
|
+
expect(blocked.status).toBe(403);
|
|
47
|
+
const body = (await blocked.json()) as { error: { data: { reason: string } } };
|
|
48
|
+
expect(body.error.data.reason).toBe("ip_denied");
|
|
49
|
+
|
|
50
|
+
const ok = await app.fetch(get("203.0.113.7"));
|
|
51
|
+
expect(ok.status).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("deny wins over allow on overlap", async () => {
|
|
55
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
56
|
+
const app = oke({ name: "ips-both" }).plug(
|
|
57
|
+
ipAllowlist({ allow: ["203.0.113.7"], deny: ["203.0.113.7"] }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const res = await app.fetch(get("203.0.113.7"));
|
|
61
|
+
expect(res.status).toBe(403);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("XFF last hop is the client; a spoofed first hop cannot bypass allow", async () => {
|
|
65
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
66
|
+
const app = oke({ name: "ips-xff" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
67
|
+
|
|
68
|
+
// Attacker-controlled first hop + trusted proxy appended the real client (last).
|
|
69
|
+
const spoofedFirst = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
70
|
+
expect(spoofedFirst.status).toBe(403);
|
|
71
|
+
|
|
72
|
+
const realLast = await app.fetch(get("198.51.100.9, 203.0.113.7"));
|
|
73
|
+
expect(realLast.status).toBe(200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("spoofed first hop cannot bypass a deny rule", async () => {
|
|
77
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
78
|
+
const app = oke({ name: "ips-xff-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
79
|
+
|
|
80
|
+
// Spoof a non-denied IP first; proxy appended the real (denied) client last.
|
|
81
|
+
const spoofed = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
82
|
+
expect(spoofed.status).toBe(403);
|
|
83
|
+
const body = (await spoofed.json()) as { error: { data: { reason: string; ip: string } } };
|
|
84
|
+
expect(body.error.data.reason).toBe("ip_denied");
|
|
85
|
+
expect(body.error.data.ip).toBe("198.51.100.9");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("trustedProxyDepth selects the hop behind N trusted proxies", async () => {
|
|
89
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
90
|
+
// Chain: spoofed, real-client, cdn-egress — depth 2 skips the nearest proxy hop.
|
|
91
|
+
const app = oke({ name: "ips-depth" }).plug(
|
|
92
|
+
ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 2 }),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const ok = await app.fetch(get("198.51.100.1, 203.0.113.7, 10.0.0.2"));
|
|
96
|
+
expect(ok.status).toBe(200);
|
|
97
|
+
|
|
98
|
+
// Second-from-last is not allow-listed → denied (depth must match topology).
|
|
99
|
+
const denied = await app.fetch(get("198.51.100.1, 198.51.100.9, 10.0.0.2"));
|
|
100
|
+
expect(denied.status).toBe(403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("trustedProxyDepth < 1 throws at construction", () => {
|
|
104
|
+
expect(() => ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 0 })).toThrow(
|
|
105
|
+
/trustedProxyDepth/i,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("missing header: denied when allow is set, permitted for deny-only", async () => {
|
|
110
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
111
|
+
const strict = oke({ name: "ips-missing-allow" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
112
|
+
|
|
113
|
+
const denied = await strict.fetch(get());
|
|
114
|
+
expect(denied.status).toBe(403);
|
|
115
|
+
|
|
116
|
+
resetBindings();
|
|
117
|
+
resetFlowSeq();
|
|
118
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
119
|
+
|
|
120
|
+
const lax = oke({ name: "ips-missing-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
121
|
+
const ok = await lax.fetch(get());
|
|
122
|
+
expect(ok.status).toBe(200);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("custom header name is honored", async () => {
|
|
126
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
127
|
+
const app = oke({ name: "ips-custom" }).plug(
|
|
128
|
+
ipAllowlist({ allow: ["203.0.113.7"], header: "x-real-ip" }),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const res = await app.fetch(
|
|
132
|
+
new Request("http://localhost/x", { headers: { "x-real-ip": "203.0.113.7" } }),
|
|
133
|
+
);
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
|
|
136
|
+
const wrongHeader = await app.fetch(get("203.0.113.7"));
|
|
137
|
+
expect(wrongHeader.status).toBe(403);
|
|
138
|
+
});
|
|
139
|
+
});
|