acrylblends 1.5.1 → 1.5.2
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/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/layouts.json +11 -0
- package/lib/cordis/door.js +5 -5
- package/lib/cordis/inspect.js +2 -2
- package/lib/cordis/state.js +3 -3
- package/lib/door/host.js +1 -1
- package/lib/{loopback-only-BbasoF7R.js → loopback-only-BoBCuDv7.js} +45 -4
- package/lib/loopback-only.d.ts +29 -1
- package/lib/loopback-only.js +2 -2
- package/lib/{middleware-BfG_fp8P.js → middleware-Do9XNe0Y.js} +1 -1
- package/lib/{snapshot-text-CXTnjxGx.js → snapshot-text-B9PWQoX6.js} +2 -2
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +3 -3
- package/plugins/acryl-router/src/middleware/index.ts +1 -1
- package/plugins/acryl-router/src/middleware/loopback-only.ts +65 -3
- package/plugins/acryl-state/src/socket.ts +2 -2
- package/plugins/agent-panel/index.ts +2 -2
package/legacy/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# The scaffolds published acrylblends releases shipped
|
|
2
2
|
|
|
3
3
|
Generated by `scripts/build-legacy-layouts.mjs`; do not edit by hand. Each folder is a template, each hash under it one distinct scaffold (deduplicated by
|
|
4
|
-
content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.5.
|
|
4
|
+
content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.5.1, 47 releases)
|
|
5
5
|
to the layouts it shipped.
|
|
6
6
|
|
|
7
7
|
`acrylblends upgrade` compares an app's scaffold files with these bytes: a file the app has not edited is brought up to date, an edited one is refused (or
|
package/legacy/layouts.json
CHANGED
|
@@ -452,6 +452,17 @@
|
|
|
452
452
|
"notes": "fb43a1c5f3b4",
|
|
453
453
|
"todo": "164274ffcf7f"
|
|
454
454
|
}
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"version": "1.5.1",
|
|
458
|
+
"layouts": {
|
|
459
|
+
"agent": "46c6eadf9230",
|
|
460
|
+
"blank": "a53d41e6ef42",
|
|
461
|
+
"chat": "d4464dfb3b04",
|
|
462
|
+
"nodejs": "a6a340b4d3a6",
|
|
463
|
+
"notes": "fb43a1c5f3b4",
|
|
464
|
+
"todo": "164274ffcf7f"
|
|
465
|
+
}
|
|
455
466
|
}
|
|
456
467
|
],
|
|
457
468
|
"templates": {
|
package/lib/cordis/door.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
2
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
3
|
-
import {
|
|
3
|
+
import { o as pageAuthority } from "../loopback-only-BoBCuDv7.js";
|
|
4
4
|
import "../helpers-BEjcdbgw.js";
|
|
5
5
|
import { n as clientModules } from "../client-modules-C5qiVroz.js";
|
|
6
6
|
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
7
|
-
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-
|
|
7
|
+
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-B9PWQoX6.js";
|
|
8
8
|
import "../src-iBJ2ysV9.js";
|
|
9
|
-
import "../middleware-
|
|
9
|
+
import "../middleware-Do9XNe0Y.js";
|
|
10
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
11
11
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
12
12
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -1355,9 +1355,9 @@ function operatorRoutes(prefix, secret, door, reportError) {
|
|
|
1355
1355
|
}
|
|
1356
1356
|
});
|
|
1357
1357
|
}
|
|
1358
|
-
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
1358
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port (or the hosted app's trusted front), and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
1359
1359
|
const ownOrigin = async (c, next) => {
|
|
1360
|
-
const authority =
|
|
1360
|
+
const authority = pageAuthority(c);
|
|
1361
1361
|
const origin = c.req.header("origin");
|
|
1362
1362
|
const site = c.req.header("sec-fetch-site");
|
|
1363
1363
|
if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
|
package/lib/cordis/inspect.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
|
-
import {
|
|
2
|
+
import { a as loopbackOnly } from "../loopback-only-BoBCuDv7.js";
|
|
3
3
|
import "../src-iBJ2ysV9.js";
|
|
4
|
-
import "../middleware-
|
|
4
|
+
import "../middleware-Do9XNe0Y.js";
|
|
5
5
|
import { Service } from "@deepseek-ai/cordis";
|
|
6
6
|
//#region ../../plugins/acryl-inspect/src/report.ts
|
|
7
7
|
/**
|
package/lib/cordis/state.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
2
|
import { t as methodNotAllowed } from "../method-not-allowed-TO2_hnfl.js";
|
|
3
3
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
4
|
-
import {
|
|
4
|
+
import { a as loopbackOnly, o as pageAuthority } from "../loopback-only-BoBCuDv7.js";
|
|
5
5
|
import "../helpers-BEjcdbgw.js";
|
|
6
6
|
import "../src-iBJ2ysV9.js";
|
|
7
|
-
import "../middleware-
|
|
7
|
+
import "../middleware-Do9XNe0Y.js";
|
|
8
8
|
import { Service } from "@deepseek-ai/cordis";
|
|
9
9
|
//#region ../../plugins/acryl-state/src/cell.ts
|
|
10
10
|
/** A writable cell. `report` receives anything a listener throws; one failing listener never blocks the others. */
|
|
@@ -305,7 +305,7 @@ export function watch(key, onValue, onError) {
|
|
|
305
305
|
`;
|
|
306
306
|
/** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
|
|
307
307
|
function refuseStranger(c) {
|
|
308
|
-
const authority =
|
|
308
|
+
const authority = pageAuthority(c);
|
|
309
309
|
if (authority === void 0) return c.json({ error: "request authority rejected" }, 403);
|
|
310
310
|
const origin = c.req.header("origin");
|
|
311
311
|
const site = c.req.header("sec-fetch-site");
|
package/lib/door/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-
|
|
1
|
+
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-B9PWQoX6.js";
|
|
2
2
|
export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
|
|
@@ -23,7 +23,7 @@ function loopbackAuthority(c, hostnames = LOOPBACK_HOSTNAMES) {
|
|
|
23
23
|
const { remoteAddress, localPort } = connectionOf(c);
|
|
24
24
|
const host = c.req.header("host");
|
|
25
25
|
if (remoteAddress === void 0 || localPort === void 0 || host === void 0) return void 0;
|
|
26
|
-
const address = (remoteAddress
|
|
26
|
+
const address = peerAddress(remoteAddress);
|
|
27
27
|
const family = isIP(address);
|
|
28
28
|
if (family === 0 || !loopback.check(address, family === 4 ? "ipv4" : "ipv6")) return void 0;
|
|
29
29
|
let authority;
|
|
@@ -35,13 +35,54 @@ function loopbackAuthority(c, hostnames = LOOPBACK_HOSTNAMES) {
|
|
|
35
35
|
if (authority.protocol !== "http:" || Number(authority.port || "80") !== localPort || !hostnames.includes(authority.hostname) || c.req.header("sec-fetch-site") === "cross-site") return void 0;
|
|
36
36
|
return authority;
|
|
37
37
|
}
|
|
38
|
+
/** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
|
|
39
|
+
function trustedFrontFromEnv(env = process.env) {
|
|
40
|
+
const origin = env.ACRYL_PUBLIC_ORIGIN?.trim();
|
|
41
|
+
const proxies = (env.ACRYL_TRUSTED_PROXY ?? "").split(",").map((peer) => peerAddress(peer.trim())).filter((peer) => peer !== "");
|
|
42
|
+
if (origin === void 0 || origin === "" || proxies.length === 0) return void 0;
|
|
43
|
+
if (originOf(origin) !== origin || !/^https?:$/u.test(new URL(origin).protocol)) return void 0;
|
|
44
|
+
if (proxies.some((peer) => isIP(peer) === 0)) return void 0;
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
origin,
|
|
47
|
+
proxies: Object.freeze(proxies)
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
let configuredFront = null;
|
|
51
|
+
/** The front of this process, read from the environment once. */
|
|
52
|
+
function trustedFront() {
|
|
53
|
+
if (configuredFront === null) configuredFront = trustedFrontFromEnv();
|
|
54
|
+
return configuredFront;
|
|
55
|
+
}
|
|
56
|
+
function peerAddress(address) {
|
|
57
|
+
return (address.replace(/^\[|\]$/gu, "").split("%", 1)[0] ?? "").replace(/^::ffff:/iu, "");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The public authority of a request the trusted front passed on: the peer is exactly one of the front's addresses, the `Host` is exactly the
|
|
61
|
+
* public host, and the browser did not mark it cross-site. Anything else, or no front, gives `undefined`. The peer comes from the transport, never a
|
|
62
|
+
* header, so a page on another box cannot claim to be the front.
|
|
63
|
+
*/
|
|
64
|
+
function frontAuthority(c, front = trustedFront()) {
|
|
65
|
+
if (front === void 0) return void 0;
|
|
66
|
+
const { remoteAddress } = connectionOf(c);
|
|
67
|
+
if (remoteAddress === void 0 || !front.proxies.includes(peerAddress(remoteAddress))) return void 0;
|
|
68
|
+
const publicOrigin = new URL(front.origin);
|
|
69
|
+
if (c.req.header("host") !== publicOrigin.host || c.req.header("sec-fetch-site") === "cross-site") return void 0;
|
|
70
|
+
return publicOrigin;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The authority a browser page of this app reached it under: the loopback one, or the trusted front's public one. For routes a page calls; an
|
|
74
|
+
* operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
|
|
75
|
+
*/
|
|
76
|
+
function pageAuthority(c, hostnames = LOOPBACK_HOSTNAMES, front = trustedFront()) {
|
|
77
|
+
return loopbackAuthority(c, hostnames) ?? frontAuthority(c, front);
|
|
78
|
+
}
|
|
38
79
|
/** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
|
|
39
80
|
function sameOrigin(c, authority) {
|
|
40
81
|
const origin = c.req.header("origin");
|
|
41
82
|
if (origin === void 0) return true;
|
|
42
83
|
try {
|
|
43
84
|
const parsed = new URL(origin);
|
|
44
|
-
return parsed.
|
|
85
|
+
return parsed.origin === authority.origin && parsed.pathname === "/" && parsed.search === "";
|
|
45
86
|
} catch {
|
|
46
87
|
return false;
|
|
47
88
|
}
|
|
@@ -75,7 +116,7 @@ function loopbackOnly(options = {}) {
|
|
|
75
116
|
await next();
|
|
76
117
|
return;
|
|
77
118
|
}
|
|
78
|
-
const authority =
|
|
119
|
+
const authority = pageAuthority(c, options.hostnames, "front" in options ? options.front : trustedFront());
|
|
79
120
|
if (authority === void 0) return c.json({ error: "request authority rejected" }, 403);
|
|
80
121
|
if (ownPage) {
|
|
81
122
|
if (!fromOwnPage(c, authority)) return c.json({ error: "only this app's own page may call this" }, 403);
|
|
@@ -87,4 +128,4 @@ function loopbackOnly(options = {}) {
|
|
|
87
128
|
};
|
|
88
129
|
}
|
|
89
130
|
//#endregion
|
|
90
|
-
export {
|
|
131
|
+
export { loopbackOnly as a, trustedFront as c, loopbackAuthority as i, trustedFrontFromEnv as l, fromOwnPage as n, pageAuthority as o, frontAuthority as r, sameOrigin as s, LOOPBACK_HOSTNAMES as t };
|
package/lib/loopback-only.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ interface LoopbackOptions {
|
|
|
24
24
|
* same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
|
|
25
25
|
*/
|
|
26
26
|
readonly callers?: 'local' | 'own-page';
|
|
27
|
+
/** The hosted app's trusted front; default: the one the environment configures ({@link trustedFront}), usually none. Tests pass one. */
|
|
28
|
+
readonly front?: TrustedFront | undefined;
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* The authority a loopback request addressed: the `Host` header as a URL, or `undefined` when the request does not come from a loopback peer to the
|
|
@@ -32,6 +34,32 @@ interface LoopbackOptions {
|
|
|
32
34
|
*/
|
|
33
35
|
declare const LOOPBACK_HOSTNAMES: readonly string[];
|
|
34
36
|
declare function loopbackAuthority(c: Context, hostnames?: readonly string[]): URL | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* A hosted app's one trusted front (spec 049 M9, design `m9-hosted-workspace.md` C): the reverse proxy that serves this app's page under a public
|
|
39
|
+
* origin, for example `https://a1b2.213-47-80-26.sslip.io`. Only the hosting template sets it, through `ACRYL_PUBLIC_ORIGIN` and
|
|
40
|
+
* `ACRYL_TRUSTED_PROXY` (the proxy's exact peer addresses, comma separated). Unset, nothing changes: an app accepts its own page on loopback only.
|
|
41
|
+
*/
|
|
42
|
+
interface TrustedFront {
|
|
43
|
+
/** The public origin the front serves this app's page from: a scheme and a host, no path. */
|
|
44
|
+
readonly origin: string;
|
|
45
|
+
/** The exact peer addresses the front connects from; no ranges, so another box on the same network is never the front. */
|
|
46
|
+
readonly proxies: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
/** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
|
|
49
|
+
declare function trustedFrontFromEnv(env?: NodeJS.ProcessEnv): TrustedFront | undefined;
|
|
50
|
+
/** The front of this process, read from the environment once. */
|
|
51
|
+
declare function trustedFront(): TrustedFront | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* The public authority of a request the trusted front passed on: the peer is exactly one of the front's addresses, the `Host` is exactly the
|
|
54
|
+
* public host, and the browser did not mark it cross-site. Anything else, or no front, gives `undefined`. The peer comes from the transport, never a
|
|
55
|
+
* header, so a page on another box cannot claim to be the front.
|
|
56
|
+
*/
|
|
57
|
+
declare function frontAuthority(c: Context, front?: TrustedFront | undefined): URL | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The authority a browser page of this app reached it under: the loopback one, or the trusted front's public one. For routes a page calls; an
|
|
60
|
+
* operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
|
|
61
|
+
*/
|
|
62
|
+
declare function pageAuthority(c: Context, hostnames?: readonly string[], front?: TrustedFront | undefined): URL | undefined;
|
|
35
63
|
/** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
|
|
36
64
|
declare function sameOrigin(c: Context, authority: URL): boolean;
|
|
37
65
|
/** Whether the request comes from a page served by this authority itself: the `own-page` rule of {@link LoopbackOptions.callers}. */
|
|
@@ -42,4 +70,4 @@ declare function fromOwnPage(c: Context, authority: URL): boolean;
|
|
|
42
70
|
*/
|
|
43
71
|
declare function loopbackOnly(options?: LoopbackOptions): MiddlewareHandler;
|
|
44
72
|
//#endregion
|
|
45
|
-
export { LOOPBACK_HOSTNAMES, LoopbackOptions, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin };
|
|
73
|
+
export { LOOPBACK_HOSTNAMES, LoopbackOptions, TrustedFront, fromOwnPage, frontAuthority, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv };
|
package/lib/loopback-only.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { LOOPBACK_HOSTNAMES, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin };
|
|
1
|
+
import { a as loopbackOnly, c as trustedFront, i as loopbackAuthority, l as trustedFrontFromEnv, n as fromOwnPage, o as pageAuthority, r as frontAuthority, s as sameOrigin, t as LOOPBACK_HOSTNAMES } from "./loopback-only-BoBCuDv7.js";
|
|
2
|
+
export { LOOPBACK_HOSTNAMES, fromOwnPage, frontAuthority, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv };
|
|
@@ -2,11 +2,11 @@ import { t as Acryl } from "./app-CQcGWjrk.js";
|
|
|
2
2
|
import { t as bodyLimit } from "./body-limit-WmIjWRLt.js";
|
|
3
3
|
import { t as methodNotAllowed } from "./method-not-allowed-TO2_hnfl.js";
|
|
4
4
|
import { r as streamSSE } from "./streaming-BlDaph8U.js";
|
|
5
|
-
import {
|
|
5
|
+
import { a as loopbackOnly, i as loopbackAuthority } from "./loopback-only-BoBCuDv7.js";
|
|
6
6
|
import "./helpers-BEjcdbgw.js";
|
|
7
7
|
import { d as parseChannelToHost, l as UiControlError, u as WINDOW_ID } from "./protocol-CQ_if7Dg.js";
|
|
8
8
|
import "./src-iBJ2ysV9.js";
|
|
9
|
-
import "./middleware-
|
|
9
|
+
import "./middleware-Do9XNe0Y.js";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
|
|
12
12
|
import { timingSafeEqual } from "node:crypto";
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { Acryl, type MiddlewareHandler } from 'acryl-router'
|
|
12
12
|
import type { UpgradeWebSocket, WSEvents } from 'acryl-router/helpers'
|
|
13
|
-
import {
|
|
13
|
+
import { pageAuthority } from 'acryl-router/middleware'
|
|
14
14
|
import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
15
15
|
import type { UiChannel } from './channel.ts'
|
|
16
16
|
import { error } from './http.ts'
|
|
@@ -25,9 +25,9 @@ export interface PageSocketOptions {
|
|
|
25
25
|
readonly heartbeatMs?: number
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
28
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port (or the hosted app's trusted front), and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
29
29
|
export const ownOrigin: MiddlewareHandler = async (c, next) => {
|
|
30
|
-
const authority =
|
|
30
|
+
const authority = pageAuthority(c)
|
|
31
31
|
const origin = c.req.header('origin')
|
|
32
32
|
const site = c.req.header('sec-fetch-site')
|
|
33
33
|
if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
|
|
@@ -25,4 +25,4 @@ export { NONCE, secureHeaders, type SecureHeadersVariables } from '../core/middl
|
|
|
25
25
|
export { timeout } from '../core/middleware/timeout/index.ts'
|
|
26
26
|
export { endTime, setMetric, startTime, timing, wrapTime, type TimingVariables } from '../core/middleware/timing/index.ts'
|
|
27
27
|
export { appendTrailingSlash, trimTrailingSlash } from '../core/middleware/trailing-slash/index.ts'
|
|
28
|
-
export { LOOPBACK_HOSTNAMES, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin, type LoopbackOptions } from './loopback-only.ts'
|
|
28
|
+
export { LOOPBACK_HOSTNAMES, frontAuthority, fromOwnPage, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv, type LoopbackOptions, type TrustedFront } from './loopback-only.ts'
|
|
@@ -26,6 +26,8 @@ export interface LoopbackOptions {
|
|
|
26
26
|
* same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
|
|
27
27
|
*/
|
|
28
28
|
readonly callers?: 'local' | 'own-page'
|
|
29
|
+
/** The hosted app's trusted front; default: the one the environment configures ({@link trustedFront}), usually none. Tests pass one. */
|
|
30
|
+
readonly front?: TrustedFront | undefined
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const loopback = new BlockList()
|
|
@@ -45,7 +47,7 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
|
|
|
45
47
|
const { remoteAddress, localPort } = connectionOf(c)
|
|
46
48
|
const host = c.req.header('host')
|
|
47
49
|
if (remoteAddress === undefined || localPort === undefined || host === undefined) return undefined
|
|
48
|
-
const address = (remoteAddress
|
|
50
|
+
const address = peerAddress(remoteAddress)
|
|
49
51
|
const family = isIP(address)
|
|
50
52
|
if (family === 0 || !loopback.check(address, family === 4 ? 'ipv4' : 'ipv6')) return undefined
|
|
51
53
|
let authority: URL
|
|
@@ -63,13 +65,73 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
|
|
|
63
65
|
return authority
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A hosted app's one trusted front (spec 049 M9, design `m9-hosted-workspace.md` C): the reverse proxy that serves this app's page under a public
|
|
70
|
+
* origin, for example `https://a1b2.213-47-80-26.sslip.io`. Only the hosting template sets it, through `ACRYL_PUBLIC_ORIGIN` and
|
|
71
|
+
* `ACRYL_TRUSTED_PROXY` (the proxy's exact peer addresses, comma separated). Unset, nothing changes: an app accepts its own page on loopback only.
|
|
72
|
+
*/
|
|
73
|
+
export interface TrustedFront {
|
|
74
|
+
/** The public origin the front serves this app's page from: a scheme and a host, no path. */
|
|
75
|
+
readonly origin: string
|
|
76
|
+
/** The exact peer addresses the front connects from; no ranges, so another box on the same network is never the front. */
|
|
77
|
+
readonly proxies: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
|
|
81
|
+
export function trustedFrontFromEnv(env: NodeJS.ProcessEnv = process.env): TrustedFront | undefined {
|
|
82
|
+
const origin = env.ACRYL_PUBLIC_ORIGIN?.trim()
|
|
83
|
+
const proxies = (env.ACRYL_TRUSTED_PROXY ?? '').split(',').map(peer => peerAddress(peer.trim())).filter(peer => peer !== '')
|
|
84
|
+
if (origin === undefined || origin === '' || proxies.length === 0) return undefined
|
|
85
|
+
if (originOf(origin) !== origin || !/^https?:$/u.test(new URL(origin).protocol)) return undefined
|
|
86
|
+
if (proxies.some(peer => isIP(peer) === 0)) return undefined
|
|
87
|
+
return Object.freeze({ origin, proxies: Object.freeze(proxies) })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let configuredFront: TrustedFront | undefined | null = null
|
|
91
|
+
|
|
92
|
+
/** The front of this process, read from the environment once. */
|
|
93
|
+
export function trustedFront(): TrustedFront | undefined {
|
|
94
|
+
if (configuredFront === null) configuredFront = trustedFrontFromEnv()
|
|
95
|
+
return configuredFront
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function peerAddress(address: string): string {
|
|
99
|
+
return (address.replace(/^\[|\]$/gu, '').split('%', 1)[0] ?? '').replace(/^::ffff:/iu, '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The public authority of a request the trusted front passed on: the peer is exactly one of the front's addresses, the `Host` is exactly the
|
|
104
|
+
* public host, and the browser did not mark it cross-site. Anything else, or no front, gives `undefined`. The peer comes from the transport, never a
|
|
105
|
+
* header, so a page on another box cannot claim to be the front.
|
|
106
|
+
*/
|
|
107
|
+
export function frontAuthority(c: Context, front: TrustedFront | undefined = trustedFront()): URL | undefined {
|
|
108
|
+
if (front === undefined) return undefined
|
|
109
|
+
const { remoteAddress } = connectionOf(c)
|
|
110
|
+
if (remoteAddress === undefined || !front.proxies.includes(peerAddress(remoteAddress))) return undefined
|
|
111
|
+
const publicOrigin = new URL(front.origin)
|
|
112
|
+
if (c.req.header('host') !== publicOrigin.host || c.req.header('sec-fetch-site') === 'cross-site') return undefined
|
|
113
|
+
return publicOrigin
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The authority a browser page of this app reached it under: the loopback one, or the trusted front's public one. For routes a page calls; an
|
|
118
|
+
* operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
|
|
119
|
+
*/
|
|
120
|
+
export function pageAuthority(
|
|
121
|
+
c: Context,
|
|
122
|
+
hostnames: readonly string[] = LOOPBACK_HOSTNAMES,
|
|
123
|
+
front: TrustedFront | undefined = trustedFront(),
|
|
124
|
+
): URL | undefined {
|
|
125
|
+
return loopbackAuthority(c, hostnames) ?? frontAuthority(c, front)
|
|
126
|
+
}
|
|
127
|
+
|
|
66
128
|
/** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
|
|
67
129
|
export function sameOrigin(c: Context, authority: URL): boolean {
|
|
68
130
|
const origin = c.req.header('origin')
|
|
69
131
|
if (origin === undefined) return true
|
|
70
132
|
try {
|
|
71
133
|
const parsed = new URL(origin)
|
|
72
|
-
return parsed.
|
|
134
|
+
return parsed.origin === authority.origin && parsed.pathname === '/' && parsed.search === ''
|
|
73
135
|
} catch {
|
|
74
136
|
return false
|
|
75
137
|
}
|
|
@@ -106,7 +168,7 @@ export function loopbackOnly(options: LoopbackOptions = {}): MiddlewareHandler {
|
|
|
106
168
|
await next()
|
|
107
169
|
return
|
|
108
170
|
}
|
|
109
|
-
const authority =
|
|
171
|
+
const authority = pageAuthority(c, options.hostnames, 'front' in options ? options.front : trustedFront())
|
|
110
172
|
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
111
173
|
if (ownPage) {
|
|
112
174
|
if (!fromOwnPage(c, authority)) return c.json({ error: 'only this app\'s own page may call this' }, 403)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Context, MiddlewareHandler } from 'acryl-router'
|
|
15
15
|
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
|
-
import {
|
|
16
|
+
import { pageAuthority } from 'acryl-router/middleware'
|
|
17
17
|
import type { ReadonlyCell } from './cell.ts'
|
|
18
18
|
|
|
19
19
|
/** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
|
|
@@ -34,7 +34,7 @@ export interface StateSocketOptions {
|
|
|
34
34
|
|
|
35
35
|
/** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
|
|
36
36
|
export function refuseStranger(c: Context): Response | undefined {
|
|
37
|
-
const authority =
|
|
37
|
+
const authority = pageAuthority(c)
|
|
38
38
|
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
39
39
|
const origin = c.req.header('origin')
|
|
40
40
|
const site = c.req.header('sec-fetch-site')
|
|
@@ -22,7 +22,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
22
22
|
import { clientModules } from '../acryl-node-server/src/client-modules.ts'
|
|
23
23
|
import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
|
|
24
24
|
import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
|
|
25
|
-
import {
|
|
25
|
+
import { pageAuthority } from '../acryl-router/src/middleware/index.ts'
|
|
26
26
|
import type {} from '../agent/index.ts'
|
|
27
27
|
import type {} from '../extensions/index.ts'
|
|
28
28
|
import type { ChatDriver, Driver } from './drivers.ts'
|
|
@@ -419,7 +419,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
419
419
|
}
|
|
420
420
|
const ws = new Acryl()
|
|
421
421
|
const own: MiddlewareHandler = async (c, next) => {
|
|
422
|
-
const authority =
|
|
422
|
+
const authority = pageAuthority(c)
|
|
423
423
|
const origin = c.req.header('origin')
|
|
424
424
|
const site = c.req.header('sec-fetch-site')
|
|
425
425
|
if (
|