@sigrun/sigrun 0.1.6 → 0.2.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/CHANGELOG.md +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +259 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +213 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +152 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/notifications.js +21 -4
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +274 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +19 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vetted-address image fetching (UI-blocks U7, spec §11.2 verbatim):
|
|
3
|
+
* the proxy's DNS-rebinding defense. Every hop is ONE `https.request`
|
|
4
|
+
* whose `lookup` option answers with the SINGLE vetted address for
|
|
5
|
+
* that hop — the connection can never re-resolve, so a resolver that
|
|
6
|
+
* answers a public address first and `127.0.0.1` second cannot cause
|
|
7
|
+
* a connection to loopback.
|
|
8
|
+
*
|
|
9
|
+
* Rules (each enforced per hop, redirects included):
|
|
10
|
+
* - https only; `http:` URLs and `http:` redirect targets are refused;
|
|
11
|
+
* - the host is resolved ONCE per hop through `deps.resolve`; every
|
|
12
|
+
* returned address is checked against the refused ranges — one bad
|
|
13
|
+
* address refuses the host (no happy-eyeballs fallback into it);
|
|
14
|
+
* - `servername` (SNI) and the `Host` header keep the ORIGINAL host
|
|
15
|
+
* name, never the resolved address;
|
|
16
|
+
* - redirects are followed BY HAND (no library follow option): at most
|
|
17
|
+
* 3 in total, each target re-resolved and re-checked the same way;
|
|
18
|
+
* - 10 s total timeout for the whole fetch — ABSOLUTE across the
|
|
19
|
+
* resolve, connect, and header phases of every hop plus the body
|
|
20
|
+
* read (a socket-inactivity timeout would never fire on a drip);
|
|
21
|
+
* - no agent reuse across hops (`agent: false`): Node's default
|
|
22
|
+
* keep-alive agent could serve a redirect hop from a pooled socket
|
|
23
|
+
* whose connection was vetted on the FIRST hop only;
|
|
24
|
+
* - 10 MiB body cap;
|
|
25
|
+
* - the body must sniff as PNG/JPEG/GIF/WebP (Content-Type is never
|
|
26
|
+
* trusted; SVG and AVIF are refused).
|
|
27
|
+
*/
|
|
28
|
+
import * as https from "node:https";
|
|
29
|
+
import { isRefusedAddress } from "./ranges.js";
|
|
30
|
+
import { isAcceptedImageType, sniffImage } from "./sniff.js";
|
|
31
|
+
/** The whole-fetch budget: 10 s, spec §11.2. */
|
|
32
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
33
|
+
/** The response body cap: 10 MiB, spec §11.2. */
|
|
34
|
+
export const FETCH_MAX_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
/** At most 3 redirects in total, spec §11.2. */
|
|
36
|
+
export const FETCH_MAX_REDIRECTS = 3;
|
|
37
|
+
/** A refusal: the reason plus the message the caller logs. */
|
|
38
|
+
export class FetchRefusal extends Error {
|
|
39
|
+
reason;
|
|
40
|
+
constructor(reason, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
this.name = "FetchRefusal";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Race `p` against the whole-fetch deadline.
|
|
47
|
+
*
|
|
48
|
+
* The deadline must be absolute even in phases a socket-inactivity
|
|
49
|
+
* timeout can never cut: a resolver that hangs, or a server dripping
|
|
50
|
+
* header bytes. Rejects with `timeout` when the deadline passes
|
|
51
|
+
* before `p` settles.
|
|
52
|
+
*/
|
|
53
|
+
function withDeadline(p, deps, deadline, phase) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const remaining = deadline - deps.now();
|
|
56
|
+
if (remaining <= 0) {
|
|
57
|
+
// Mark `p`'s (possible later) rejection handled: the
|
|
58
|
+
// deadline refusal is the one the caller sees.
|
|
59
|
+
void p.catch(() => { });
|
|
60
|
+
reject(new FetchRefusal("timeout", `deadline reached before ${phase}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
reject(new FetchRefusal("timeout", `deadline reached ${phase}`));
|
|
65
|
+
}, remaining);
|
|
66
|
+
p.then((value) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve(value);
|
|
69
|
+
}, (cause) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
reject(cause);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Resolve and vet one hop's host, or refuse it. */
|
|
76
|
+
async function vettedAddress(host, deps) {
|
|
77
|
+
let addresses;
|
|
78
|
+
try {
|
|
79
|
+
addresses = await deps.resolve(host);
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new FetchRefusal("resolve", `resolving ${host}: ${String(cause)}`);
|
|
83
|
+
}
|
|
84
|
+
if (addresses.length === 0) {
|
|
85
|
+
throw new FetchRefusal("resolve", `${host} resolved to no addresses`);
|
|
86
|
+
}
|
|
87
|
+
const vet = deps.vet ?? isRefusedAddress;
|
|
88
|
+
for (const address of addresses) {
|
|
89
|
+
if (vet(address)) {
|
|
90
|
+
throw new FetchRefusal("refused-address", `${host} resolves to refused address ${address}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The FIRST vetted address is the one the connection uses; the
|
|
94
|
+
// lookup callback never consults the resolver again.
|
|
95
|
+
return addresses[0];
|
|
96
|
+
}
|
|
97
|
+
/** One `https.request` hop: connect to `address`, keep SNI/Host.
|
|
98
|
+
*
|
|
99
|
+
* The connect+header phase is cut by an ABSOLUTE whole-fetch deadline
|
|
100
|
+
* timer (not `req.setTimeout`, which is socket-inactivity: a
|
|
101
|
+
* header-drip server resets it forever). The timer is cleared the
|
|
102
|
+
* moment headers resolve; the body read has its own deadline timer.
|
|
103
|
+
*/
|
|
104
|
+
function hop(options) {
|
|
105
|
+
const { url, address, deps, deadline } = options;
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const remaining = deadline - deps.now();
|
|
108
|
+
if (remaining <= 0) {
|
|
109
|
+
reject(new FetchRefusal("timeout", "deadline reached before connecting"));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const req = https.request({
|
|
113
|
+
protocol: "https:",
|
|
114
|
+
host: url.hostname,
|
|
115
|
+
port: url.port || 443,
|
|
116
|
+
servername: url.hostname,
|
|
117
|
+
path: `${url.pathname}${url.search}`,
|
|
118
|
+
method: "GET",
|
|
119
|
+
// The rebinding defense: the connection dials the ONE
|
|
120
|
+
// vetted address, never the hostname (and never a second
|
|
121
|
+
// resolution). Node calls `lookup` with `all: true`.
|
|
122
|
+
lookup(_host, opts, callback) {
|
|
123
|
+
const family = address.includes(":") ? 6 : 4;
|
|
124
|
+
if (opts && typeof opts === "object" && "all" in opts && opts.all) {
|
|
125
|
+
callback(null, [{ address, family }]);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
callback(null, address, family);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
// No explicit Host header: Node derives it from the
|
|
132
|
+
// ORIGINAL hostname (and the port), never the address the
|
|
133
|
+
// lookup returned.
|
|
134
|
+
ca: deps.ca,
|
|
135
|
+
// NO agent reuse across hops: with the default keep-alive
|
|
136
|
+
// global agent (Node >= 19), a redirect hop to the same
|
|
137
|
+
// host:port could be served by the pooled FIRST-hop
|
|
138
|
+
// socket — whose lookup (and vetted address) is the first
|
|
139
|
+
// hop's, so this hop's vetting would never run.
|
|
140
|
+
agent: false,
|
|
141
|
+
}, (res) => {
|
|
142
|
+
// Headers are in: the hop's absolute timer comes down; the
|
|
143
|
+
// caller owns the stream (redirect hops drain it, the
|
|
144
|
+
// final hop reads it).
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve({ status: res.statusCode ?? 0, headers: res.headers, stream: res });
|
|
147
|
+
});
|
|
148
|
+
let timedOut = false;
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
timedOut = true;
|
|
151
|
+
req.destroy();
|
|
152
|
+
reject(new FetchRefusal("timeout", `deadline reached awaiting headers from ${url.hostname}`));
|
|
153
|
+
}, remaining);
|
|
154
|
+
// The absolute timer: cleared on headers (above) AND on a
|
|
155
|
+
// connect-phase error — an early failure must not leave a timer
|
|
156
|
+
// holding the event loop (or fake clocks) open for the full
|
|
157
|
+
// remaining budget (Task 3 pickup of Task 2's review P2).
|
|
158
|
+
req.on("error", (cause) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (timedOut)
|
|
161
|
+
return; // the destroy() above already rejected
|
|
162
|
+
reject(new FetchRefusal("network", `connecting to ${url.hostname}: ${cause.message}`));
|
|
163
|
+
});
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Read a body stream under the deadline and the byte cap. */
|
|
168
|
+
function readBody(stream, deps, deadline) {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const chunks = [];
|
|
171
|
+
let received = 0;
|
|
172
|
+
const fail = (refusal) => {
|
|
173
|
+
stream.destroy?.();
|
|
174
|
+
reject(refusal);
|
|
175
|
+
};
|
|
176
|
+
// The whole-fetch deadline: checked on every read and by a
|
|
177
|
+
// timer, so a drip that stops delivering entirely is cut too.
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
fail(new FetchRefusal("timeout", "deadline reached reading body"));
|
|
180
|
+
}, Math.max(0, deadline - deps.now()));
|
|
181
|
+
stream.on("data", (chunk) => {
|
|
182
|
+
received += chunk.length;
|
|
183
|
+
if (received > FETCH_MAX_BYTES) {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
fail(new FetchRefusal("too-large", `body over ${FETCH_MAX_BYTES} bytes`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
chunks.push(chunk);
|
|
189
|
+
const remaining = deadline - deps.now();
|
|
190
|
+
if (remaining <= 0) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
fail(new FetchRefusal("timeout", "deadline reached reading body"));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
stream.on("end", () => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
resolve(Buffer.concat(chunks));
|
|
198
|
+
});
|
|
199
|
+
stream.on("error", (cause) => {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
fail(new FetchRefusal("network", `reading body: ${cause.message}`));
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Parse `Cache-Control: max-age=N` from response headers. */
|
|
206
|
+
function maxAgeOf(headers) {
|
|
207
|
+
const raw = headers["cache-control"];
|
|
208
|
+
if (raw === undefined)
|
|
209
|
+
return undefined;
|
|
210
|
+
for (const header of Array.isArray(raw) ? raw : [raw]) {
|
|
211
|
+
for (const directive of header.split(",")) {
|
|
212
|
+
const trimmed = directive.trim();
|
|
213
|
+
if (trimmed.startsWith("max-age=")) {
|
|
214
|
+
const parsed = Number(trimmed.slice("max-age=".length));
|
|
215
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Fetch `u` as an image: resolve-and-vet every hop, follow at most 3
|
|
224
|
+
* redirects by hand, cap the body, and require the sniffed type.
|
|
225
|
+
*/
|
|
226
|
+
export async function fetchImage(u, deps) {
|
|
227
|
+
const deadline = deps.now() + FETCH_TIMEOUT_MS;
|
|
228
|
+
let current;
|
|
229
|
+
try {
|
|
230
|
+
current = new URL(u);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
throw new FetchRefusal("bad-url", `not a URL: ${u}`);
|
|
234
|
+
}
|
|
235
|
+
if (current.protocol !== "https:") {
|
|
236
|
+
throw new FetchRefusal("scheme", `not https: ${current.protocol}`);
|
|
237
|
+
}
|
|
238
|
+
let redirects = 0;
|
|
239
|
+
// Each loop iteration is one hop: vet, connect, then either take
|
|
240
|
+
// the redirect (draining and looping) or read the body.
|
|
241
|
+
for (;;) {
|
|
242
|
+
// Every hop resolves through `deps.resolve` — including IP
|
|
243
|
+
// literal hosts (the real resolver, `dns.lookup`, passes literals
|
|
244
|
+
// through verbatim, so literals get range-checked like any other
|
|
245
|
+
// answer). Brackets stripped for the resolver and SNI; the Host
|
|
246
|
+
// header keeps Node's own derivation from the URL. The deadline
|
|
247
|
+
// is ABSOLUTE here too: a resolver that hangs is cut at it.
|
|
248
|
+
const bareHost = current.hostname.replace(/^\[|\]$/g, "");
|
|
249
|
+
const address = await withDeadline(vettedAddress(bareHost, deps), deps, deadline, `resolving ${bareHost}`);
|
|
250
|
+
const response = await hop({ url: current, address, deps, deadline });
|
|
251
|
+
const status = response.status;
|
|
252
|
+
if (status >= 300 && status < 400 && response.headers.location !== undefined) {
|
|
253
|
+
// Redirect hops count against the same total budget even
|
|
254
|
+
// when refused for scheme or address — the attempt was made.
|
|
255
|
+
if (redirects >= FETCH_MAX_REDIRECTS) {
|
|
256
|
+
void consume(response.stream);
|
|
257
|
+
throw new FetchRefusal("too-many-redirects", `over ${FETCH_MAX_REDIRECTS} redirects at ${current.toString()}`);
|
|
258
|
+
}
|
|
259
|
+
redirects += 1;
|
|
260
|
+
let target;
|
|
261
|
+
try {
|
|
262
|
+
target = new URL(response.headers.location, current);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
void consume(response.stream);
|
|
266
|
+
throw new FetchRefusal("bad-url", `redirect target not a URL: ${u}`);
|
|
267
|
+
}
|
|
268
|
+
if (target.protocol !== "https:") {
|
|
269
|
+
void consume(response.stream);
|
|
270
|
+
throw new FetchRefusal("redirect-scheme", `redirect to ${target.protocol} refused`);
|
|
271
|
+
}
|
|
272
|
+
// Drain the redirect response so the server sees it consumed
|
|
273
|
+
// and the socket closes cleanly (with `agent: false` there is
|
|
274
|
+
// no pool for it to return to — the drain is hygiene, not reuse).
|
|
275
|
+
void consume(response.stream);
|
|
276
|
+
current = target;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (status < 200 || status >= 300) {
|
|
280
|
+
void consume(response.stream);
|
|
281
|
+
throw new FetchRefusal("http-status", `status ${status} from ${current.toString()}`);
|
|
282
|
+
}
|
|
283
|
+
const bytes = await readBody(response.stream, deps, deadline);
|
|
284
|
+
const sniffed = sniffImage(bytes);
|
|
285
|
+
if (!isAcceptedImageType(sniffed)) {
|
|
286
|
+
throw new FetchRefusal("bad-type", sniffed === undefined
|
|
287
|
+
? `body did not sniff as an accepted image type`
|
|
288
|
+
: `body sniffed as ${sniffed}, which is refused`);
|
|
289
|
+
}
|
|
290
|
+
const maxAge = maxAgeOf(response.headers);
|
|
291
|
+
return { bytes, type: sniffed, ...(maxAge === undefined ? {} : { maxAge }) };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Best-effort drain of a refused hop's response so the server sees
|
|
295
|
+
* the response consumed and the socket closes cleanly. */
|
|
296
|
+
function consume(stream) {
|
|
297
|
+
return new Promise((resolve) => {
|
|
298
|
+
stream.on("data", () => { });
|
|
299
|
+
stream.on("end", () => resolve());
|
|
300
|
+
stream.on("error", () => resolve());
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refused-address ranges for image-proxy fetching (UI-blocks U7,
|
|
3
|
+
* spec §11.2 verbatim): loopback, `0.0.0.0/8`, RFC 1918 private,
|
|
4
|
+
* link-local, CGNAT `100.64.0.0/10`, unique-local `fc00::/7`,
|
|
5
|
+
* multicast, and the IPv4-mapped IPv6 forms of all of them.
|
|
6
|
+
*
|
|
7
|
+
* IPv4-mapped literals (`::ffff:10.0.0.1`) are normalized by
|
|
8
|
+
* `BlockList.check`, so the IPv4 rules cover every mapped form in
|
|
9
|
+
* both dotted and full 8-group notation. Anything that is not an IP
|
|
10
|
+
* literal at all is refused too (fail closed): the fetcher connects
|
|
11
|
+
* only to literals the vetting understood.
|
|
12
|
+
*/
|
|
13
|
+
import { BlockList, isIP } from "node:net";
|
|
14
|
+
const REFUSED = new BlockList();
|
|
15
|
+
// IPv4
|
|
16
|
+
REFUSED.addSubnet("0.0.0.0", 8, "ipv4");
|
|
17
|
+
REFUSED.addSubnet("127.0.0.0", 8, "ipv4");
|
|
18
|
+
REFUSED.addSubnet("10.0.0.0", 8, "ipv4");
|
|
19
|
+
REFUSED.addSubnet("172.16.0.0", 12, "ipv4");
|
|
20
|
+
REFUSED.addSubnet("192.168.0.0", 16, "ipv4");
|
|
21
|
+
REFUSED.addSubnet("169.254.0.0", 16, "ipv4");
|
|
22
|
+
REFUSED.addSubnet("100.64.0.0", 10, "ipv4");
|
|
23
|
+
REFUSED.addSubnet("224.0.0.0", 4, "ipv4");
|
|
24
|
+
REFUSED.addAddress("255.255.255.255", "ipv4");
|
|
25
|
+
// IPv6
|
|
26
|
+
// (Spec-level gap, known: the deprecated IPv4-compatible form
|
|
27
|
+
// `::127.0.0.1` is NOT refused — only the IPv4-mapped `::ffff:` form
|
|
28
|
+
// normalizes into the IPv4 rules.)
|
|
29
|
+
REFUSED.addSubnet("::", 128, "ipv6");
|
|
30
|
+
REFUSED.addSubnet("::1", 128, "ipv6");
|
|
31
|
+
REFUSED.addSubnet("fc00::", 7, "ipv6");
|
|
32
|
+
REFUSED.addSubnet("fe80::", 10, "ipv6");
|
|
33
|
+
REFUSED.addSubnet("ff00::", 8, "ipv6");
|
|
34
|
+
/** True when `ip` is an address the image proxy must never connect to. */
|
|
35
|
+
export function isRefusedAddress(ip) {
|
|
36
|
+
const family = isIP(ip);
|
|
37
|
+
if (family === 4)
|
|
38
|
+
return REFUSED.check(ip, "ipv4");
|
|
39
|
+
if (family === 6)
|
|
40
|
+
return REFUSED.check(ip, "ipv6");
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image-source rewriter (UI-blocks U7 Task 1, spec §11.1/§11.2):
|
|
3
|
+
* walks a tree or data JSON value and replaces `https://` image sources
|
|
4
|
+
* with the signer's proxy URL before the value leaves the server:
|
|
5
|
+
*
|
|
6
|
+
* - an `image` block's string `src` (literals only — a binding resolves
|
|
7
|
+
* client-side against the data, whose own slots are rewritten below);
|
|
8
|
+
* - a card `image` slot (a string `image` prop), in a tree's item
|
|
9
|
+
* template AND in data items (spec §5.5.1: the slot takes a binding
|
|
10
|
+
* relative to the item, so the URL usually lives in the data);
|
|
11
|
+
* - `` markdown image links in any string (markdown
|
|
12
|
+
* text, card/callout bodies, and the data strings they bind to).
|
|
13
|
+
*
|
|
14
|
+
* `asset:` and `data:` sources are untouched (§11.1). The walk is pure:
|
|
15
|
+
* the input is never mutated, and a branch is rebuilt only when a
|
|
16
|
+
* descendant changed (copy-on-write, the discipline of
|
|
17
|
+
* `shared/blocks/ops.ts`); an untouched subtree is returned by
|
|
18
|
+
* reference.
|
|
19
|
+
*
|
|
20
|
+
* Scope note (a spec-reading decision): the whole-string rewrite keys
|
|
21
|
+
* on the two shapes the block set defines for image sources — the
|
|
22
|
+
* `image` block's `src` prop and the card `image` prop — plus markdown
|
|
23
|
+
* image links. A binding that resolves to an https URL stored in data
|
|
24
|
+
* under any OTHER prop name is not rewritten (the walk cannot trace
|
|
25
|
+
* bindings); the webui's `img-src 'self' data:` CSP (Task 3) makes such
|
|
26
|
+
* a miss fail closed rather than load.
|
|
27
|
+
*/
|
|
28
|
+
/** A markdown image link: ``. */
|
|
29
|
+
const MARKDOWN_IMAGE = /(!\[[^\]]*\]\()(https:\/\/[^)\s]+)(\))/g;
|
|
30
|
+
/** Whether `s` is an https image source the rewriter replaces. */
|
|
31
|
+
function isHttps(s) {
|
|
32
|
+
return s.startsWith("https://");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Rewrite one string's markdown image links; `null` when nothing
|
|
36
|
+
* matched (so the caller keeps the original reference).
|
|
37
|
+
*/
|
|
38
|
+
function rewriteMarkdown(text, sign) {
|
|
39
|
+
let changed = false;
|
|
40
|
+
const out = text.replace(MARKDOWN_IMAGE, (_all, pre, url, post) => {
|
|
41
|
+
changed = true;
|
|
42
|
+
return `${pre}${sign(url)}${post}`;
|
|
43
|
+
});
|
|
44
|
+
return changed ? out : null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rewrite every https image source in a tree or data value. Pure; see
|
|
48
|
+
* the module docstring for the three rewrite shapes and the scope note.
|
|
49
|
+
*/
|
|
50
|
+
export function rewriteImages(value, sign) {
|
|
51
|
+
return rewrite(value, sign);
|
|
52
|
+
}
|
|
53
|
+
/** The structural walk (copy-on-write: `null`-free, returns a value). */
|
|
54
|
+
function rewrite(value, sign) {
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
const md = rewriteMarkdown(value, sign);
|
|
57
|
+
return md === null ? value : md;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
let changed = false;
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const entry of value) {
|
|
63
|
+
const rewritten = rewrite(entry, sign);
|
|
64
|
+
if (rewritten !== entry)
|
|
65
|
+
changed = true;
|
|
66
|
+
out.push(rewritten);
|
|
67
|
+
}
|
|
68
|
+
return changed ? out : value;
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === "object" && value !== null) {
|
|
71
|
+
let changed = false;
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
74
|
+
let rewritten;
|
|
75
|
+
if (typeof entry === "string" &&
|
|
76
|
+
(key === "src" || key === "image") &&
|
|
77
|
+
isHttps(entry)) {
|
|
78
|
+
// The two whole-string shapes: an image block's `src` and a
|
|
79
|
+
// card's `image` slot. Markdown links inside the string (none
|
|
80
|
+
// in a bare URL) are covered by the string branch below, so
|
|
81
|
+
// rewrite directly.
|
|
82
|
+
rewritten = sign(entry);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
rewritten = rewrite(entry, sign);
|
|
86
|
+
}
|
|
87
|
+
if (rewritten !== entry)
|
|
88
|
+
changed = true;
|
|
89
|
+
out[key] = rewritten;
|
|
90
|
+
}
|
|
91
|
+
return changed ? out : value;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signing half of the image proxy (UI-blocks U7 Task 1, spec §11.2
|
|
3
|
+
* verbatim):
|
|
4
|
+
*
|
|
5
|
+
* - `s` is HMAC-SHA256 (hex) over the exact bytes `u + "\n" + e`, where
|
|
6
|
+
* `u` is the URL string as it appeared in the tree — the server never
|
|
7
|
+
* re-parses or re-encodes it between verifying and fetching.
|
|
8
|
+
* - `e` is an expiry 24 hours after signing (epoch milliseconds).
|
|
9
|
+
* - The key is 32 random bytes at `<stateDir>/image-proxy.key`, mode
|
|
10
|
+
* 0600, created at first boot and reused after. Deleting the file
|
|
11
|
+
* rotates it: old signatures fail and channels re-fetch the surface,
|
|
12
|
+
* which re-signs.
|
|
13
|
+
* - Signatures are not bound to a user; a copied signed URL works for
|
|
14
|
+
* any signed-in user until it expires (spec: that reveals nothing the
|
|
15
|
+
* URL didn't already).
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
/** The key file's name under the state dir. */
|
|
22
|
+
export const IMAGE_PROXY_KEY_FILE = "image-proxy.key";
|
|
23
|
+
/** The signature lifetime (spec §11.2): 24 hours. */
|
|
24
|
+
export const SIGNATURE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
/** The proxy path every signed URL is rooted at. */
|
|
26
|
+
const PROXY_PATH = "/v1/image";
|
|
27
|
+
/** Load (or create) the proxy key at `<stateDir>/image-proxy.key`. */
|
|
28
|
+
export async function loadOrCreateKey(stateDir) {
|
|
29
|
+
const path = join(stateDir, IMAGE_PROXY_KEY_FILE);
|
|
30
|
+
try {
|
|
31
|
+
return await readFile(path);
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
if (cause.code !== "ENOENT")
|
|
35
|
+
throw cause;
|
|
36
|
+
}
|
|
37
|
+
// First boot: create the key atomically (stage + chmod + rename, the
|
|
38
|
+
// same discipline as the stores' `writeFileAtomic` — chmod BEFORE the
|
|
39
|
+
// rename so there is no window at the wrong mode, and a chmod beats
|
|
40
|
+
// writeFile's `mode` option because umask masks the latter).
|
|
41
|
+
await mkdir(stateDir, { recursive: true });
|
|
42
|
+
const key = randomBytes(32);
|
|
43
|
+
const staged = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(staged, key, { mode: 0o600 });
|
|
46
|
+
await chmod(staged, 0o600);
|
|
47
|
+
await rename(staged, path);
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
await rm(staged, { force: true });
|
|
51
|
+
throw cause;
|
|
52
|
+
}
|
|
53
|
+
return key;
|
|
54
|
+
}
|
|
55
|
+
/** Sign one URL at time `now`: `/v1/image?u=…&e=…&s=…`. */
|
|
56
|
+
export function signUrl(key, u, now) {
|
|
57
|
+
const e = String(now + SIGNATURE_TTL_MS);
|
|
58
|
+
const s = signature(key, u, e);
|
|
59
|
+
return `${PROXY_PATH}?u=${encodeURIComponent(u)}&e=${e}&s=${s}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Verify a signed URL's parts at time `now`: the HMAC matches (constant
|
|
63
|
+
* time) AND `now <= e`. Malformed parts (a non-numeric `e`, a wrong-length
|
|
64
|
+
* or non-hex `s`) reject instead of throwing.
|
|
65
|
+
*/
|
|
66
|
+
export function verify(key, u, e, s, now) {
|
|
67
|
+
if (!/^\d+$/.test(e))
|
|
68
|
+
return false;
|
|
69
|
+
if (!/^[0-9a-f]+$/.test(s))
|
|
70
|
+
return false;
|
|
71
|
+
const expected = signature(key, u, e);
|
|
72
|
+
// Constant-time over the fixed 64-byte hex digests; the length check
|
|
73
|
+
// first because timingSafeEqual throws on a length mismatch.
|
|
74
|
+
if (s.length !== expected.length)
|
|
75
|
+
return false;
|
|
76
|
+
if (!timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(s, "utf8"))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return now <= Number(e);
|
|
80
|
+
}
|
|
81
|
+
/** The HMAC hex digest over the exact bytes `u + "\n" + e`. */
|
|
82
|
+
function signature(key, u, e) {
|
|
83
|
+
return createHmac("sha256", key).update(`${u}\n${e}`, "utf8").digest("hex");
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content sniffing for image-proxy responses (UI-blocks U7, spec
|
|
3
|
+
* §11.2): the body must sniff as PNG, JPEG, GIF or WebP — the
|
|
4
|
+
* `Content-Type` header is never trusted. SVG and AVIF are refused
|
|
5
|
+
* even when the header says `image/png` (SVG can carry script; AVIF
|
|
6
|
+
* has no decoder in pi-tui).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Sniff `bytes` by magic prefix. Returns `"svg"` / `"avif"` for the
|
|
10
|
+
* refused-but-recognizable types (so callers can name the reason),
|
|
11
|
+
* and `undefined` when nothing matches.
|
|
12
|
+
*/
|
|
13
|
+
export function sniffImage(bytes) {
|
|
14
|
+
if (bytes.length >= 8) {
|
|
15
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
16
|
+
if (bytes[0] === 0x89 &&
|
|
17
|
+
bytes[1] === 0x50 &&
|
|
18
|
+
bytes[2] === 0x4e &&
|
|
19
|
+
bytes[3] === 0x47 &&
|
|
20
|
+
bytes[4] === 0x0d &&
|
|
21
|
+
bytes[5] === 0x0a &&
|
|
22
|
+
bytes[6] === 0x1a &&
|
|
23
|
+
bytes[7] === 0x0a) {
|
|
24
|
+
return "png";
|
|
25
|
+
}
|
|
26
|
+
// GIF: GIF87a / GIF89a
|
|
27
|
+
if (bytes.subarray(0, 3).toString("latin1") === "GIF" &&
|
|
28
|
+
(bytes.subarray(3, 6).toString("latin1") === "87a" ||
|
|
29
|
+
bytes.subarray(3, 6).toString("latin1") === "89a")) {
|
|
30
|
+
return "gif";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// JPEG: FF D8 FF
|
|
34
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
35
|
+
return "jpeg";
|
|
36
|
+
}
|
|
37
|
+
// WebP: "RIFF" + len + "WEBP"
|
|
38
|
+
if (bytes.length >= 12 &&
|
|
39
|
+
bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
40
|
+
bytes.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
41
|
+
return "webp";
|
|
42
|
+
}
|
|
43
|
+
// AVIF/HEIF: "ftyp" box at offset 4; the brand at offset 8 is
|
|
44
|
+
// avif/avis (or the heic family — refused all the same, but named
|
|
45
|
+
// avif only for actual avif brands so the reason is honest).
|
|
46
|
+
if (bytes.length >= 12 && bytes.subarray(4, 8).toString("latin1") === "ftyp") {
|
|
47
|
+
const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase();
|
|
48
|
+
if (brand === "avif" || brand === "avis")
|
|
49
|
+
return "avif";
|
|
50
|
+
}
|
|
51
|
+
// SVG: an XML prolog or an `<svg` opening anywhere in the first
|
|
52
|
+
// 512 bytes (handles BOM, XML decl, comments, and DOCTYPE).
|
|
53
|
+
const head = bytes.subarray(0, 512).toString("utf8");
|
|
54
|
+
if (/^\uFEFF?\s*(<\?xml[\s\S]*?\?>\s*)?(<!--[\s\S]*?-->\s*)*(!DOCTYPE[\s\S]*?\]\s*)?<svg/i.test(head)) {
|
|
55
|
+
return "svg";
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** True only for the four accepted types (the refused ones reject). */
|
|
60
|
+
export function isAcceptedImageType(type) {
|
|
61
|
+
return type === "png" || type === "jpeg" || type === "gif" || type === "webp";
|
|
62
|
+
}
|