agent-dag 1.33.145 → 1.33.147
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/web/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-BaqIyhG1.js"></script>
|
|
44
44
|
<link rel="stylesheet" crossorigin href="/assets/index-DhGa0tR_.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.147",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -50,6 +50,30 @@ export const installSpec = (spec = "latest", platform = process.platform) =>
|
|
|
50
50
|
npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
|
|
51
51
|
"--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform);
|
|
52
52
|
|
|
53
|
+
// npx is the same kind of shim as npm, and the fallback run needs the same
|
|
54
|
+
// treatment for a second, sharper reason: its argument vector carries a
|
|
55
|
+
// user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
|
|
56
|
+
// /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
|
|
57
|
+
// route is a GET, any page open in the user's browser could aim it at the
|
|
58
|
+
// loopback port with no CORS, no preflight and no need to read the answer.
|
|
59
|
+
// spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
|
|
60
|
+
// the vector untouched everywhere else, so nothing in it is ever parsed as
|
|
61
|
+
// syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
|
|
62
|
+
// .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
|
|
63
|
+
// spawn can launch at all (PATHEXT is a shell's job), so asking for the
|
|
64
|
+
// extensionless name would trade a shell injection for an ENOENT.
|
|
65
|
+
function npxSpec(args, platform = process.platform) {
|
|
66
|
+
return spawnSpec(platform === "win32" ? "npx.cmd" : "npx", args, platform);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
|
|
71
|
+
* Exported for tests: the platform is a parameter so the Windows command line
|
|
72
|
+
* can be checked from any OS.
|
|
73
|
+
*/
|
|
74
|
+
export const fallbackSpec = (args = [], platform = process.platform) =>
|
|
75
|
+
npxSpec(["-y", "ccusage@latest", ...args], platform);
|
|
76
|
+
|
|
53
77
|
let _installing = null; // Promise guard so concurrent calls share one install
|
|
54
78
|
let _checkedThisRun = false; // only kick the daily check once per process boot
|
|
55
79
|
|
|
@@ -188,19 +212,20 @@ async function getRunner() {
|
|
|
188
212
|
// Run ccusage with the given args, resolve raw stdout.
|
|
189
213
|
async function runCcusage(args) {
|
|
190
214
|
const runner = await getRunner();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
// Neither branch gets a shell. The managed install is `node <entry> …`, which
|
|
216
|
+
// never needed one; the npx fallback is routed through spawnSpec instead —
|
|
217
|
+
// see npxSpec, and note that `args` here ends in whatever /api/ccusage was
|
|
218
|
+
// asked for.
|
|
219
|
+
const { file, args: full, opts } = runner.kind === "node"
|
|
220
|
+
? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
|
|
221
|
+
: fallbackSpec(args);
|
|
194
222
|
return new Promise((resolve, reject) => {
|
|
195
|
-
const child = spawn(
|
|
196
|
-
// node path needs no shell; npx fallback does (Windows .cmd shim).
|
|
197
|
-
shell: runner.kind === "npx",
|
|
198
|
-
windowsHide: true,
|
|
199
|
-
});
|
|
223
|
+
const child = spawn(file, full, { windowsHide: true, ...opts });
|
|
200
224
|
let out = "", err = "";
|
|
201
225
|
const timer = setTimeout(() => {
|
|
202
|
-
// The npx fallback
|
|
203
|
-
// and npx is a grandchild that a plain kill would leave
|
|
226
|
+
// The npx fallback goes through cmd.exe on Windows, so `child` is the
|
|
227
|
+
// wrapper there and npx is a grandchild that a plain kill would leave
|
|
228
|
+
// downloading.
|
|
204
229
|
killTree(child);
|
|
205
230
|
reject(tagged("timeout", "ccusage timed out"));
|
|
206
231
|
}, TIMEOUT_MS);
|
package/src/server/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { extname, join, resolve, dirname as pdirname } from "node:path";
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
12
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
13
|
import { PRODUCT } from "./brand.mjs";
|
|
14
14
|
import { invokedName } from "./invoked-as.mjs";
|
|
@@ -1469,14 +1469,40 @@ async function handleCodexQuota(req, res) {
|
|
|
1469
1469
|
send(res, 200, quota);
|
|
1470
1470
|
}
|
|
1471
1471
|
|
|
1472
|
+
/**
|
|
1473
|
+
* ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
|
|
1474
|
+
* at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
|
|
1475
|
+
*
|
|
1476
|
+
* Eight digits is a shape, not a calendar, and deliberately so. What this gate
|
|
1477
|
+
* is for is keeping anything that is not a date out of a child process's
|
|
1478
|
+
* argument vector; `99999999` is exactly as inert an argument as `20260101`,
|
|
1479
|
+
* and whether a date outside the logs means an empty chart is ccusage's
|
|
1480
|
+
* question to answer rather than the deck's to guess at.
|
|
1481
|
+
*/
|
|
1482
|
+
export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
|
|
1483
|
+
|
|
1472
1484
|
async function handleCcusage(req, res) {
|
|
1473
|
-
const { fetchCcusageDaily } = await import(
|
|
1474
|
-
pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
|
|
1475
|
-
);
|
|
1476
1485
|
const url = new URL(req.url, "http://localhost");
|
|
1477
1486
|
const force = url.searchParams.get("refresh") === "1";
|
|
1478
1487
|
const since = url.searchParams.get("since") || undefined;
|
|
1479
1488
|
const until = url.searchParams.get("until") || undefined;
|
|
1489
|
+
// Refused here, at the boundary, because this is where a string chosen by
|
|
1490
|
+
// someone else enters: /api/ccusage is a GET, so it needs no CORS, no
|
|
1491
|
+
// preflight and no ability to read the response — any page the user has open
|
|
1492
|
+
// can fire it at the loopback port — and both values end up in the argv of a
|
|
1493
|
+
// spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
|
|
1494
|
+
// this is the second lock rather than the only one, and it is the one that
|
|
1495
|
+
// keeps the pair honest if a shell is ever reintroduced downstream.
|
|
1496
|
+
if (!isCliDate(since) || !isCliDate(until)) {
|
|
1497
|
+
return send(res, 400, {
|
|
1498
|
+
ok: false,
|
|
1499
|
+
reason: "bad_range",
|
|
1500
|
+
error: "since and until must be YYYYMMDD dates, e.g. 20260101",
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
const { fetchCcusageDaily } = await import(
|
|
1504
|
+
pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
|
|
1505
|
+
);
|
|
1480
1506
|
const data = await fetchCcusageDaily({ since, until, force });
|
|
1481
1507
|
send(res, 200, data);
|
|
1482
1508
|
}
|
|
@@ -1672,9 +1698,20 @@ export function challengeProof(token, nonce) {
|
|
|
1672
1698
|
//
|
|
1673
1699
|
// The nonce is the caller's, so the answer proves knowledge of the token
|
|
1674
1700
|
// without disclosing it, and proves it for this exchange only. Answering
|
|
1675
|
-
// freely is
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1701
|
+
// freely is what the exchange requires: the hook is asking whether the process
|
|
1702
|
+
// on this port is the deck that wrote the discovery file, and it asks precisely
|
|
1703
|
+
// because it does not yet know — a deck that demanded credentials before
|
|
1704
|
+
// answering could not be told apart from a stranger that refuses.
|
|
1705
|
+
//
|
|
1706
|
+
// So this route is an oracle, and it must stay one. That makes its answer
|
|
1707
|
+
// useless as a credential FOR this server, and nothing here may ever accept it
|
|
1708
|
+
// as one: a caller who can GET this can obtain a valid proof for any nonce, so
|
|
1709
|
+
// a gate honouring proofs is a gate honouring anybody. See presentsDeckToken,
|
|
1710
|
+
// which takes the token itself and refuses the hashed form for this reason.
|
|
1711
|
+
//
|
|
1712
|
+
// What the free answer does NOT give away is the token: the response is a
|
|
1713
|
+
// one-way hash of it, and after the read gate above a rebound page cannot see
|
|
1714
|
+
// even that.
|
|
1678
1715
|
function handleHookChallenge(_req, res, url) {
|
|
1679
1716
|
const nonce = url.searchParams.get("nonce") ?? "";
|
|
1680
1717
|
if (!nonce || nonce.length > 256) return send(res, 400, { error: "bad nonce" });
|
|
@@ -1726,6 +1763,41 @@ export function requestUrl(rawUrl) {
|
|
|
1726
1763
|
catch { return null; }
|
|
1727
1764
|
}
|
|
1728
1765
|
|
|
1766
|
+
// Is this request allowed to be answered at all, whatever it asks for?
|
|
1767
|
+
//
|
|
1768
|
+
// The rebinding gate below was reached only by mutations, on the reasoning that
|
|
1769
|
+
// a cross-site page cannot read a loopback reply. That is true of a genuinely
|
|
1770
|
+
// cross-origin page and false of a rebound one: the browser resolved
|
|
1771
|
+
// attacker.example to 127.0.0.1 itself, so it calls the reply same-origin and
|
|
1772
|
+
// hands the body to the page. Every read was therefore open to exactly the
|
|
1773
|
+
// attack the mutation gate was written to stop — and the reads are where the
|
|
1774
|
+
// secrets are. GET /api/events is the whole ring buffer: prompt text, the Bash
|
|
1775
|
+
// command lines the agent ran, the paths and contents it wrote, the contents of
|
|
1776
|
+
// every file it read back. /api/claude-accounts names the accounts,
|
|
1777
|
+
// /api/claude-accounts/login carries a live OAuth authorize URL, /api/sound-hook
|
|
1778
|
+
// the user's own hook command lines, /api/health the absolute workspace path.
|
|
1779
|
+
//
|
|
1780
|
+
// So the Host check runs for every method now. What it asks is only the
|
|
1781
|
+
// rebinding question — did this request arrive addressed to a name that can
|
|
1782
|
+
// only ever be this machine — and it asks it of browser-shaped requests alone,
|
|
1783
|
+
// meaning anything carrying an Origin or fetch metadata. A client sending
|
|
1784
|
+
// neither is not a page and has no ambient authority to borrow: that is
|
|
1785
|
+
// hook/hook.js, a plain Node http.request from the user's own machine, and it
|
|
1786
|
+
// keeps reaching the deck under whatever name it used before.
|
|
1787
|
+
//
|
|
1788
|
+
// Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
|
|
1789
|
+
// applies. `cross-site` on a read is an ordinary top-level navigation — a link
|
|
1790
|
+
// to http://localhost:4317 clicked on any page — and the document it loads is
|
|
1791
|
+
// the deck's own UI on the deck's own origin, which is not an attack and used
|
|
1792
|
+
// to work. Rebinding does not need that test either: a rebound page's requests
|
|
1793
|
+
// report `same-origin`, and it is the Host that gives it away.
|
|
1794
|
+
export function isTrustedRead({ origin, host, secFetchSite } = {}) {
|
|
1795
|
+
const browserShaped = (typeof origin === "string" && origin !== "")
|
|
1796
|
+
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
|
|
1797
|
+
if (!browserShaped) return true;
|
|
1798
|
+
return isLoopbackHost(host);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1729
1801
|
// Is this mutating request allowed to be acted on?
|
|
1730
1802
|
//
|
|
1731
1803
|
// The deck binds 127.0.0.1, which sounds private but is reachable from every
|
|
@@ -1769,8 +1841,10 @@ export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
|
|
|
1769
1841
|
const hasOrigin = typeof origin === "string" && origin !== "";
|
|
1770
1842
|
if (!hasOrigin && !site) return true;
|
|
1771
1843
|
// Either header present means a browser sent this, so the rebinding gate
|
|
1772
|
-
// applies even to the shape that carries fetch metadata but no Origin.
|
|
1773
|
-
|
|
1844
|
+
// applies even to the shape that carries fetch metadata but no Origin. Every
|
|
1845
|
+
// request reaching this line is browser-shaped by the test just above, so
|
|
1846
|
+
// isTrustedRead here is exactly its isLoopbackHost half.
|
|
1847
|
+
if (!isTrustedRead({ origin, host, secFetchSite })) return false;
|
|
1774
1848
|
return hasOrigin ? originMatchesHost(origin, host) : true;
|
|
1775
1849
|
}
|
|
1776
1850
|
|
|
@@ -1817,6 +1891,127 @@ function originMatchesHost(origin, host) {
|
|
|
1817
1891
|
return fromOrigin !== "" && fromOrigin === fromHost;
|
|
1818
1892
|
}
|
|
1819
1893
|
|
|
1894
|
+
// Mutating routes that ask nothing of the caller beyond the gates above.
|
|
1895
|
+
//
|
|
1896
|
+
// Only the hook's ingest, and only because hook/hook.js is installed OUTSIDE
|
|
1897
|
+
// this package — it lives in the user's ~/.claude and is loaded by whatever
|
|
1898
|
+
// Claude Code session is already running. Requiring a credential here would
|
|
1899
|
+
// stop every event from every session whose hook predates this change, on a
|
|
1900
|
+
// machine where nothing is obviously broken and nothing says why, until each
|
|
1901
|
+
// one is reinstalled. It also carries no credential and destroys nothing: the
|
|
1902
|
+
// worst a caller does with it is draw a session on the canvas that is not
|
|
1903
|
+
// there. See the handshake in hook/hook.js for the authentication that does
|
|
1904
|
+
// run on this path, which is the deck proving itself to the hook.
|
|
1905
|
+
const OPEN_MUTATIONS = new Set(["/api/event"]);
|
|
1906
|
+
|
|
1907
|
+
// Constant-time comparison of two secrets, and a length test that is not.
|
|
1908
|
+
// Lengths differ freely in public — a mismatched one only says "not this
|
|
1909
|
+
// token" — but timingSafeEqual throws rather than answering false when they do.
|
|
1910
|
+
function secretEquals(given, expected) {
|
|
1911
|
+
const a = Buffer.from(String(given ?? ""), "utf8");
|
|
1912
|
+
const b = Buffer.from(String(expected ?? ""), "utf8");
|
|
1913
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
1914
|
+
return timingSafeEqual(a, b);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// Does this request carry the deck's own token?
|
|
1918
|
+
//
|
|
1919
|
+
// One spelling, `x-ccdeck-token`, carrying the token itself. That is all a
|
|
1920
|
+
// local process needs, because the only way it can hold the token is to have
|
|
1921
|
+
// read it out of a file only it can open.
|
|
1922
|
+
//
|
|
1923
|
+
// The token is HOOK_TOKEN, fresh per deck and written to the discovery file at
|
|
1924
|
+
// mode 0600. That mode is the whole of the access control: the answer to "who
|
|
1925
|
+
// may do this" is "whoever can read that file", which is the user this deck
|
|
1926
|
+
// runs as and nobody else. On Windows the chmod is a best-effort no-op, so
|
|
1927
|
+
// there the file's protection is the per-user ACL on the profile directory it
|
|
1928
|
+
// sits in rather than a mode bit; the token still works identically.
|
|
1929
|
+
//
|
|
1930
|
+
// The hashed challengeProof form is deliberately NOT accepted here, and this
|
|
1931
|
+
// is the paragraph that must be read before anyone adds it. GET
|
|
1932
|
+
// /api/hook-challenge?nonce=… answers challengeProof(HOOK_TOKEN, nonce) to any
|
|
1933
|
+
// caller for any nonce, by design — so accepting a proof as a credential means
|
|
1934
|
+
// the server hands out its own credentials on request, and two unauthenticated
|
|
1935
|
+
// GETs are a complete bypass of everything below. This gate shipped that way
|
|
1936
|
+
// for one commit; the regression test in csrf-origin.test.ts pins it shut.
|
|
1937
|
+
//
|
|
1938
|
+
// The premise that makes the oracle safe is the one this gate rejects, which is
|
|
1939
|
+
// exactly why the two cannot share a secret: the challenge is the deck proving
|
|
1940
|
+
// itself TO the hook, not the hook proving itself to the deck. hook/hook.js
|
|
1941
|
+
// already holds the token — it read the same 0600 file — and challenges the
|
|
1942
|
+
// port to find out whether the process listening there is really the deck that
|
|
1943
|
+
// wrote it, or something else that inherited the port number from a stale
|
|
1944
|
+
// discovery file. Nothing travels in the other direction, so no caller ever
|
|
1945
|
+
// needed the hashed form as a way IN, and a client entitled to mutate holds the
|
|
1946
|
+
// token itself anyway.
|
|
1947
|
+
function presentsDeckToken(headers = {}) {
|
|
1948
|
+
const raw = headers["x-ccdeck-token"];
|
|
1949
|
+
return typeof raw === "string" && secretEquals(raw.trim(), HOOK_TOKEN);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Is this the deck's own page in the user's browser?
|
|
1953
|
+
//
|
|
1954
|
+
// A strict subset of isTrustedMutation, which every request here has already
|
|
1955
|
+
// passed: an Origin must actually be present — a browser sends one on every
|
|
1956
|
+
// POST, including a same-origin one — it must name this very server, the Host
|
|
1957
|
+
// must be a loopback identity, and any fetch metadata must say `same-origin`.
|
|
1958
|
+
// `none` is excluded on purpose: that is a top-level navigation the user typed
|
|
1959
|
+
// or a form submitted from one, never the UI's own fetch.
|
|
1960
|
+
function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
|
|
1961
|
+
const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
|
|
1962
|
+
if (site !== "" && site !== "same-origin") return false;
|
|
1963
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
1964
|
+
if (!isLoopbackHost(host)) return false;
|
|
1965
|
+
return originMatchesHost(origin, host);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// May this request change something?
|
|
1969
|
+
//
|
|
1970
|
+
// Everything above this line is about the browser: whether a page chose the
|
|
1971
|
+
// request, and whether it was addressed to this machine. None of it asks who
|
|
1972
|
+
// the caller is, and for a client that is not a browser at all nothing did —
|
|
1973
|
+
// a request carrying no Origin and no fetch metadata was waved through on the
|
|
1974
|
+
// reasoning that a process able to POST here can already run anything as the
|
|
1975
|
+
// user. That holds on a single-user laptop and fails twice elsewhere. Loopback
|
|
1976
|
+
// is not scoped to a UID, so on a shared box or a multi-tenant container every
|
|
1977
|
+
// other account on the machine can reach this port; and a sandboxed subprocess
|
|
1978
|
+
// denied the credential store but allowed loopback egress — the ordinary shape
|
|
1979
|
+
// of an agent's Bash sandbox — reaches the same credentials through the API.
|
|
1980
|
+
// `curl -XPOST localhost:4317/api/claude-accounts/admin -d '{"action":"share"}'`
|
|
1981
|
+
// answered with the account's live OAuth refresh token in the clear, and the
|
|
1982
|
+
// same request reached account remove and import, the live account switch, a
|
|
1983
|
+
// global `npm i -g`, the restart and the event log.
|
|
1984
|
+
//
|
|
1985
|
+
// So a mutation must now be either of two things:
|
|
1986
|
+
//
|
|
1987
|
+
// - the user's own browser tab, recognised by the headers a page cannot
|
|
1988
|
+
// forge and the deck's own loopback address (isDeckUiRequest), or
|
|
1989
|
+
// - a client holding the deck's token, which it can only have read from the
|
|
1990
|
+
// 0600 discovery file (presentsDeckToken).
|
|
1991
|
+
//
|
|
1992
|
+
// What this does NOT do, stated plainly so nobody mistakes it for more: the
|
|
1993
|
+
// browser clause rests on headers that page script cannot set but any local
|
|
1994
|
+
// program can. A local attacker who adds `Origin` and `Sec-Fetch-Site` to the
|
|
1995
|
+
// request is back through. That is not a gap left by laziness — there is no
|
|
1996
|
+
// fix for it here. Authenticating the tab would mean giving the tab a secret,
|
|
1997
|
+
// and every channel from this process to a browser on the same machine is
|
|
1998
|
+
// readable by any other process on that machine: a token in the served
|
|
1999
|
+
// index.html is read by the same `curl http://localhost:4317/` that the gate
|
|
2000
|
+
// is meant to stop, and a token in the URL the deck opens is read out of the
|
|
2001
|
+
// browser's argv by `ps`. What is closed is the free pass — a request that
|
|
2002
|
+
// presents nothing at all no longer changes anything — and what is opened is
|
|
2003
|
+
// the honest door, so a script of the user's own authenticates by reading the
|
|
2004
|
+
// token instead of impersonating a page.
|
|
2005
|
+
function isAuthorizedMutation(req) {
|
|
2006
|
+
const headers = req?.headers ?? {};
|
|
2007
|
+
if (presentsDeckToken(headers)) return true;
|
|
2008
|
+
return isDeckUiRequest({
|
|
2009
|
+
origin: headers.origin,
|
|
2010
|
+
host: headers.host,
|
|
2011
|
+
secFetchSite: headers["sec-fetch-site"],
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1820
2015
|
// A request handler rejected. Two audiences, two different amounts of detail:
|
|
1821
2016
|
// the operator, who needs the whole error — stack included — to find the bug,
|
|
1822
2017
|
// and the HTTP client, which needs to know only that the request failed.
|
|
@@ -1882,9 +2077,23 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1882
2077
|
// would be an uncaughtException inside the listener — i.e. the whole deck.
|
|
1883
2078
|
if (!url) return send(res, 400, { error: "bad request target" });
|
|
1884
2079
|
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
2080
|
+
// Three gates in front of the whole table, so every route is covered and
|
|
2081
|
+
// any route added later is covered too, without the author having to
|
|
2082
|
+
// remember. They ask three different questions and they run in the order
|
|
2083
|
+
// that answers the cheapest first.
|
|
2084
|
+
|
|
2085
|
+
// Was this addressed to this machine? Every method, because a rebound page
|
|
2086
|
+
// reads a reply as readily as it writes a request. See isTrustedRead.
|
|
2087
|
+
if (!isTrustedRead({
|
|
2088
|
+
origin: req.headers.origin,
|
|
2089
|
+
host: req.headers.host,
|
|
2090
|
+
secFetchSite: req.headers["sec-fetch-site"],
|
|
2091
|
+
})) {
|
|
2092
|
+
return send(res, 403, { error: "cross-site request blocked" });
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Did a page choose this? Mutations only — the same-site read this refuses
|
|
2096
|
+
// is an ordinary navigation. See isTrustedMutation.
|
|
1888
2097
|
if (req.method !== "GET" && req.method !== "HEAD" && !isTrustedMutation({
|
|
1889
2098
|
origin: req.headers.origin,
|
|
1890
2099
|
host: req.headers.host,
|
|
@@ -1893,6 +2102,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1893
2102
|
return send(res, 403, { error: "cross-site request blocked" });
|
|
1894
2103
|
}
|
|
1895
2104
|
|
|
2105
|
+
// And who is asking? Refusing by default is the point: a mutating route
|
|
2106
|
+
// added later is protected until someone deliberately lists it as open,
|
|
2107
|
+
// which is the direction this has to fail in. See isAuthorizedMutation.
|
|
2108
|
+
if (req.method !== "GET" && req.method !== "HEAD"
|
|
2109
|
+
&& !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
|
|
2110
|
+
return send(res, 401, { error: "unauthenticated" });
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1896
2113
|
// `?persist=0` — another deck was elected to write this event to the log
|
|
1897
2114
|
// the two of them share. Absent, this deck writes it.
|
|
1898
2115
|
if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
|