opencode-plugboard 0.9.1 → 0.11.0

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/README.md CHANGED
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.9.1",
33
+ "package": "opencode-plugboard@0.11.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
@@ -19,11 +19,20 @@
19
19
  },
20
20
  "files": ["index.ts", "src/", "README.md", "LICENSE"],
21
21
  "scripts": {
22
- "typecheck": "tsc --noEmit",
22
+ "typecheck": "tsc --noEmit && tsc --noEmit -p test",
23
+ "test": "bun test",
24
+ "smoke:pack": "bun .github/scripts/smoke-pack.ts",
23
25
  "release": "bun .github/scripts/release.ts"
24
26
  },
27
+ "dependencies": {
28
+ "escape-html": "1.0.3",
29
+ "ipaddr.js": "2.5.0",
30
+ "safe-stable-stringify": "2.5.0"
31
+ },
25
32
  "devDependencies": {
26
33
  "@opencode/plugin": "2.0.26",
34
+ "@types/bun": "1.4.2",
35
+ "@types/escape-html": "^1.0.4",
27
36
  "@types/node": "^26.6.2",
28
37
  "effect": "4.0.0-rc.112",
29
38
  "typescript": "^5.9.3"
package/src/abort.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** The one "settle or reject when aborted" helper that request-side code races host calls against. */
2
+
3
+ /**
4
+ * Settles with `promise`, or rejects with `signal.reason` as soon as `signal` aborts. The promise
5
+ * itself keeps running (it cannot be cancelled): it is always observed, so a late rejection after
6
+ * the race was lost never becomes an unhandled rejection. A signal that is already aborted rejects
7
+ * at once.
8
+ */
9
+ export function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
10
+ promise.catch(() => {})
11
+ if (signal.aborted) return Promise.reject(signal.reason)
12
+ return new Promise<T>((resolve, reject) => {
13
+ const onAbort = () => reject(signal.reason)
14
+ signal.addEventListener("abort", onAbort, { once: true })
15
+ promise.then(
16
+ (value) => {
17
+ signal.removeEventListener("abort", onAbort)
18
+ resolve(value)
19
+ },
20
+ (error) => {
21
+ signal.removeEventListener("abort", onAbort)
22
+ reject(error)
23
+ },
24
+ )
25
+ })
26
+ }
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
2
2
 
3
+ import { createHash, timingSafeEqual } from "node:crypto"
3
4
  import type { PlugError } from "./contract.js"
4
5
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
5
6
  import type { HistoryLimits } from "./session/prune.js"
@@ -125,6 +126,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
125
126
  }
126
127
  // Own-property construction: a key such as `__proto__` must stay a key, never reach the prototype.
127
128
  const keys: Record<string, string> = Object.fromEntries(keyEntries)
129
+ digestsOf(keys)
128
130
  if (keyEntries.length === 0 && !isLoopbackHost(host))
129
131
  throw new Error(`plugboard refuses to listen on non-loopback host \`${host}\` without \`keys\``)
130
132
 
@@ -186,20 +188,30 @@ const UNAUTHORIZED: PlugError = {
186
188
  code: "invalid_api_key",
187
189
  }
188
190
 
189
- function same(a: string, b: string): boolean {
190
- let diff = a.length ^ b.length
191
- const length = Math.max(a.length, b.length)
192
- for (let i = 0; i < length; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0)
193
- return diff === 0
191
+ const sha256 = (text: string): Buffer => createHash("sha256").update(text).digest()
192
+
193
+ /**
194
+ * SHA-256 digest of each key, computed once per `keys` object (at parse; lazily for an options
195
+ * object built without `parseOptions`). Comparing fixed-size digests means a wrong token's length
196
+ * and content do not change how long the comparison takes.
197
+ */
198
+ const keyDigests = new WeakMap<object, ReadonlyArray<readonly [string, Buffer]>>()
199
+
200
+ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readonly [string, Buffer]> {
201
+ let digests = keyDigests.get(keys)
202
+ if (!digests) keyDigests.set(keys, (digests = Object.entries(keys).map(([name, key]) => [name, sha256(key)] as const)))
203
+ return digests
194
204
  }
195
205
 
196
206
  /** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
197
207
  export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
198
- const entries = Object.entries(options.keys)
208
+ const entries = digestsOf(options.keys)
199
209
  if (entries.length === 0) return { app: DEFAULT_APP }
200
210
  const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
201
211
  if (!token) return UNAUTHORIZED
212
+ const digest = sha256(token)
202
213
  let app: string | undefined
203
- for (const [name, key] of entries) if (same(token, key)) app = name
214
+ // Every key is compared, no early exit.
215
+ for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
204
216
  return app === undefined ? UNAUTHORIZED : { app }
205
217
  }
package/src/discovery.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ServerResponse } from "node:http"
19
+ import escapeText from "escape-html"
19
20
  import type { PlugboardOptions } from "./config.js"
20
21
  import type { CatalogModel } from "./contract.js"
21
22
  import { listAvailableModels } from "./models.js"
@@ -40,8 +41,7 @@ export function sendText(response: ServerResponse, status: number, contentType:
40
41
  response.end(body)
41
42
  }
42
43
 
43
- const escapeHtml = (value: unknown): string =>
44
- String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char)
44
+ const escapeHtml = (value: unknown): string => escapeText(String(value))
45
45
 
46
46
  /** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
47
47
  function origin(listen: PlugboardOptions["listen"]): string {
package/src/http.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { IncomingMessage, ServerResponse } from "node:http"
8
+ import { raceAbort } from "./abort.js"
8
9
  import { isLoopbackHost, type PlugboardOptions } from "./config.js"
9
10
  import type { PlugError, PlugEvent } from "./contract.js"
10
11
 
@@ -49,9 +50,9 @@ export interface RequestScope {
49
50
 
50
51
  /**
51
52
  * Establishes cancellation and the deadline. Call before the first `await` of a request: a socket
52
- * that is already gone aborts immediately, so abandoned work never starts.
53
+ * that is already gone aborts immediately, so abandoned work never starts. `graceMs` is for tests.
53
54
  */
54
- export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number): RequestScope {
55
+ export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number, graceMs = DEADLINE_GRACE_MS): RequestScope {
55
56
  const controller = new AbortController()
56
57
  const abort = (kind: RequestAborted["kind"]) => {
57
58
  if (!controller.signal.aborted) controller.abort(new RequestAborted(kind))
@@ -68,7 +69,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
68
69
  graceTimer = setTimeout(() => {
69
70
  response.destroy()
70
71
  request.socket?.destroy()
71
- }, DEADLINE_GRACE_MS)
72
+ }, graceMs)
72
73
  graceTimer.unref?.()
73
74
  }
74
75
  timer = setTimeout(onDeadline, Math.min(Math.max(timeoutMs, 1), MAX_TIMER_MS))
@@ -98,24 +99,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
98
99
  code: "timeout",
99
100
  message: `Request timed out after ${timeoutMs} ms (option requestTimeoutMs)`,
100
101
  },
101
- race<T>(promise: Promise<T>): Promise<T> {
102
- promise.catch(() => {})
103
- if (signal.aborted) return Promise.reject(signal.reason)
104
- return new Promise<T>((resolve, reject) => {
105
- const onAbort = () => reject(signal.reason)
106
- signal.addEventListener("abort", onAbort, { once: true })
107
- promise.then(
108
- (value) => {
109
- signal.removeEventListener("abort", onAbort)
110
- resolve(value)
111
- },
112
- (error) => {
113
- signal.removeEventListener("abort", onAbort)
114
- reject(error)
115
- },
116
- )
117
- })
118
- },
102
+ race: (promise) => raceAbort(promise, signal),
119
103
  dispose,
120
104
  }
121
105
  }
@@ -79,10 +79,16 @@ const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFini
79
79
  message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
80
80
  }
81
81
 
82
+ /**
83
+ * `committed`: whether the transport already committed a 200 stream before any event was emitted
84
+ * (the SSE writer does at the first reasoning event, #49). A failure then still opens the response
85
+ * and ends it with `response.failed`, instead of leaving the client an empty 200 stream.
86
+ */
82
87
  export async function runResponse(
83
88
  events: AsyncIterable<PlugEvent>,
84
89
  exchange: ResponsesExchange,
85
90
  emit: (event: ResponsesStreamEvent) => void,
91
+ committed: () => boolean = () => false,
86
92
  ): Promise<ResponseRun> {
87
93
  const id = newId("resp")
88
94
  const createdAt = Math.floor(Date.now() / 1000)
@@ -203,14 +209,15 @@ export async function runResponse(
203
209
  const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
204
210
  failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
205
211
  if (failure) {
206
- if (!started) return { failure, started }
212
+ if (!started && !committed()) return { failure, started }
213
+ start()
207
214
  closeMessage()
208
215
  const response = envelope("failed", items, {
209
216
  error: { code: failure.code ?? failure.kind, message: failure.message },
210
217
  ...(usage ? { usage: encodeUsage(usage) } : {}),
211
218
  })
212
219
  send("response.failed", { response })
213
- return { failure, started, response }
220
+ return { failure, started: true, response }
214
221
  }
215
222
  start()
216
223
  if (held && structuredAnswer) text(args(held.input))
@@ -56,7 +56,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
56
56
 
57
57
  if (exchange.stream) {
58
58
  const sse = sseWriter(response, scope.signal)
59
- const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
59
+ const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
60
60
  if (!sse.started && !response.headersSent) {
61
61
  if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
62
62
  } else {
package/src/runtime.ts CHANGED
@@ -44,7 +44,10 @@
44
44
  */
45
45
 
46
46
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
47
+ import { setTimeout as delay } from "node:timers/promises"
47
48
  import { Cause, Effect, Stream } from "effect"
49
+ import stableStringify from "safe-stable-stringify"
50
+ import { raceAbort } from "./abort.js"
48
51
  import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
49
52
  import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
50
53
  import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
@@ -104,8 +107,8 @@ const BACKEND_POLL_MS = 25
104
107
  const BIND_RETRY_MS = 15_000
105
108
  const BIND_REMINDER_MS = 5 * 60_000
106
109
 
107
- /** 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
108
- const VERSION = 6
110
+ /** 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
111
+ const VERSION = 7
109
112
  const KEY = Symbol.for("opencode-plugboard.runtime")
110
113
 
111
114
  interface Listener {
@@ -298,19 +301,9 @@ export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<Catalog
298
301
  return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
299
302
  }
300
303
 
301
- /** JSON with object keys sorted at every level, so equal options compare equal whatever their key order. */
304
+ /** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
302
305
  function canonicalJson(value: unknown): string {
303
- const canonical = (item: unknown): unknown => {
304
- if (Array.isArray(item)) return item.map(canonical)
305
- if (typeof item !== "object" || item === null) return item
306
- return Object.fromEntries(
307
- Object.entries(item)
308
- .filter(([, entry]) => entry !== undefined)
309
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
310
- .map(([key, entry]) => [key, canonical(entry)]),
311
- )
312
- }
313
- return JSON.stringify(canonical(value))
306
+ return stableStringify(value) ?? ""
314
307
  }
315
308
 
316
309
  const unavailable = (response: ServerResponse, message: string) => {
@@ -512,30 +505,8 @@ export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, neve
512
505
 
513
506
  const backendUnavailable = (message: string): PlugError => ({ status: 503, kind: "server", code: "backend_unavailable", message })
514
507
 
515
- /** Rejects when `signal` aborts; the underlying promise is left running (its late result or error is swallowed). */
516
- function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
517
- return new Promise<T>((resolve, reject) => {
518
- const onAbort = () => reject(new Error("aborted"))
519
- if (signal.aborted) return onAbort()
520
- signal.addEventListener("abort", onAbort, { once: true })
521
- promise.then(
522
- (value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
523
- (error) => (signal.removeEventListener("abort", onAbort), reject(error)),
524
- )
525
- })
526
- }
527
-
528
- const sleep = (ms: number, signal: AbortSignal) =>
529
- new Promise<void>((resolve) => {
530
- if (signal.aborted) return resolve()
531
- const timer = setTimeout(done, ms)
532
- function done() {
533
- clearTimeout(timer)
534
- signal.removeEventListener("abort", done)
535
- resolve()
536
- }
537
- signal.addEventListener("abort", done, { once: true })
538
- })
508
+ /** Waits `ms`, or until `signal` aborts (which resolves, never rejects). */
509
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> => delay(ms, undefined, { signal }).catch(() => {})
539
510
 
540
511
  /** Rebuild the transforms of a generation set up before its directory was confirmed; shared by concurrent requests. */
541
512
  function activate(generation: Generation): Promise<void> {
@@ -593,7 +564,7 @@ export async function ensureBackend(
593
564
  if (!booted) {
594
565
  booted = true
595
566
  try {
596
- await abortable(boot(directory), bounded)
567
+ await raceAbort(boot(directory), bounded)
597
568
  } catch (error) {
598
569
  if (bounded.aborted) continue
599
570
  logError("backend.boot_failed", { app, error: errorMessage(error) })
@@ -603,7 +574,7 @@ export async function ensureBackend(
603
574
  }
604
575
  } else {
605
576
  try {
606
- await abortable(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
577
+ await raceAbort(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
607
578
  } catch (error) {
608
579
  if (bounded.aborted) continue
609
580
  throw error
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { randomUUID } from "node:crypto"
20
+ import { raceAbort } from "../abort.js"
20
21
  import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
21
22
  import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
22
23
  import { log, logError } from "../log.js"
@@ -72,17 +73,6 @@ function checkPreparation(request: PlugRequest, images: number, maxTools: number
72
73
  return undefined
73
74
  }
74
75
 
75
- /** Settles with `promise`, or rejects as soon as `signal` aborts. Request side only: the promise itself keeps running. */
76
- function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
77
- promise.catch(() => {}) // a late rejection after the race was lost must not become unhandled
78
- if (signal.aborted) return Promise.reject(signal.reason)
79
- return new Promise<T>((resolve, reject) => {
80
- const onAbort = () => reject(signal.reason)
81
- signal.addEventListener("abort", onAbort, { once: true })
82
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
83
- })
84
- }
85
-
86
76
  export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
87
77
  const { port, sessions, maxTools, ensureBackend } = deps
88
78
  const remoteImages = deps.remoteImages ?? false
@@ -9,15 +9,17 @@
9
9
  * server's own network (#84): the host is resolved first, every address it resolves to must be a
10
10
  * public one (loopback, private, link-local, CGNAT, ULA, multicast, reserved and mapped or
11
11
  * translated forms of those are refused) unless the operator's `remoteImages.allow` list names the
12
- * host or the address, and the request then connects to the address that was checked, not to the
13
- * name, so a second DNS answer cannot redirect it (DNS rebinding). The caller sees one generic
14
- * error for every fetch failure, whatever the cause.
12
+ * host or the address, and the request then connects to the addresses that were checked (tried in
13
+ * order until one accepts), not to the name, so a second DNS answer cannot redirect it (DNS
14
+ * rebinding). Addresses are classified by ipaddr.js. The caller sees one generic error for every
15
+ * fetch failure, whatever the cause.
15
16
  */
16
17
 
17
18
  import { lookup } from "node:dns/promises"
18
19
  import { request as httpRequest, type IncomingMessage } from "node:http"
19
20
  import { request as httpsRequest } from "node:https"
20
21
  import { isIP } from "node:net"
22
+ import ipaddr from "ipaddr.js"
21
23
  import type { PlugRequest } from "../contract.js"
22
24
  import { log } from "../log.js"
23
25
 
@@ -49,127 +51,64 @@ export class ImageError extends Error {
49
51
 
50
52
  // ---- addresses
51
53
 
52
- interface Ip {
53
- readonly family: 4 | 6
54
- readonly bytes: ReadonlyArray<number>
55
- }
54
+ type Ip = ipaddr.IPv4 | ipaddr.IPv6
56
55
 
57
- /** Dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone) to bytes. */
56
+ /** Strict dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone). Odd IPv4 spellings (`0x7f.1`) never get here: a URL host is normalized by `new URL` first. */
58
57
  function parseIp(text: string): Ip | undefined {
59
- const family = isIP(text)
60
- if (family === 4) return { family, bytes: text.split(".").map(Number) }
61
- if (family !== 6) return undefined
62
- let head = text.split("%")[0]!
63
- const dot = head.lastIndexOf(".")
64
- if (dot !== -1) {
65
- const colon = head.lastIndexOf(":")
66
- const v4 = head.slice(colon + 1).split(".").map(Number)
67
- head = `${head.slice(0, colon + 1)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`
68
- }
69
- const [left = "", right = ""] = head.split("::")
70
- const first = left === "" ? [] : left.split(":")
71
- const last = head.includes("::") ? (right === "" ? [] : right.split(":")) : []
72
- const groups = head.includes("::") ? [...first, ...Array<string>(8 - first.length - last.length).fill("0"), ...last] : head.split(":")
73
- const bytes: number[] = []
74
- for (const group of groups) {
75
- const value = Number.parseInt(group, 16)
76
- bytes.push(value >> 8, value & 0xff)
77
- }
78
- return { family, bytes }
79
- }
80
-
81
- interface Cidr {
82
- readonly ip: Ip
83
- readonly bits: number
84
- }
85
-
86
- function inCidr(ip: Ip, cidr: Cidr): boolean {
87
- if (ip.family !== cidr.ip.family) return false
88
- let left = cidr.bits
89
- for (let i = 0; left > 0; i++, left -= 8) {
90
- const mask = left >= 8 ? 0xff : (0xff << (8 - left)) & 0xff
91
- if ((ip.bytes[i]! & mask) !== (cidr.ip.bytes[i]! & mask)) return false
58
+ if (isIP(text) === 0) return undefined
59
+ try {
60
+ return ipaddr.parse(text)
61
+ } catch {
62
+ return undefined
92
63
  }
93
- return true
94
64
  }
95
65
 
96
- function parseCidr(text: string): Cidr | undefined {
97
- const [address = "", length, extra] = text.split("/")
98
- const ip = parseIp(address)
99
- if (!ip || extra !== undefined) return undefined
100
- const max = ip.family === 4 ? 32 : 128
101
- if (length === undefined) return { ip, bits: max }
102
- const bits = Number(length)
103
- return /^\d{1,3}$/.test(length) && bits <= max ? { ip, bits } : undefined
104
- }
66
+ /** `address/bits` match; false for another family (ipaddr.js throws on one). */
67
+ const inNet = (ip: Ip, net: Ip, bits: number): boolean => ip.kind() === net.kind() && ip.match(net, bits)
105
68
 
106
- const cidrs = (list: string[]): Cidr[] => list.map((entry) => parseCidr(entry)!)
69
+ const NAT64 = ipaddr.IPv6.parseCIDR("64:ff9b::/96")
70
+ /** `::/96`: unspecified, loopback and the deprecated IPv4-compatible form (`::7f00:1`), which ipaddr.js calls unicast. */
71
+ const IPV4_COMPATIBLE = ipaddr.IPv6.parseCIDR("::/96")
107
72
 
108
- /** IPv4 that is not a public unicast address. */
109
- const BLOCKED_V4 = cidrs([
110
- "0.0.0.0/8", // "this network"
111
- "10.0.0.0/8",
112
- "100.64.0.0/10", // CGNAT
113
- "127.0.0.0/8", // loopback
114
- "169.254.0.0/16", // link-local, cloud metadata
115
- "172.16.0.0/12",
116
- "192.0.0.0/24", // IETF protocol assignments
117
- "192.0.2.0/24", // documentation
118
- "192.168.0.0/16",
119
- "198.18.0.0/15", // benchmarking
120
- "198.51.100.0/24", // documentation
121
- "203.0.113.0/24", // documentation
122
- "224.0.0.0/4", // multicast
123
- "240.0.0.0/4", // reserved, broadcast
124
- ])
73
+ const v4From = (high: number, low: number) => new ipaddr.IPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
125
74
 
126
75
  /**
127
- * IPv6 that is not a public unicast address. NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are
128
- * not listed: they embed an IPv4 address, which `isPublic` checks as IPv4 (a DNS64 network answers
129
- * with NAT64 addresses for ordinary public hosts).
76
+ * A public unicast address. ipaddr.js names the special ranges (loopback, private, link-local, CGNAT,
77
+ * multicast, reserved and documentation, ULA, Teredo, local-use NAT64, IPv4-mapped, ...); only
78
+ * `unicast` passes. NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) embed an IPv4 address, which is
79
+ * checked as IPv4 instead of being refused outright (a DNS64 network answers with NAT64 addresses
80
+ * for ordinary public hosts). An IPv4-mapped address is never a real answer for a public host.
130
81
  */
131
- const BLOCKED_V6 = cidrs([
132
- "::/96", // unspecified, loopback, deprecated IPv4-compatible
133
- "::ffff:0:0/96", // IPv4-mapped: never a real answer for a public host
134
- "64:ff9b:1::/48", // local-use NAT64
135
- "100::/64", // discard
136
- "2001::/32", // Teredo
137
- "2001:db8::/32", // documentation
138
- "fc00::/7", // unique local
139
- "fe80::/10", // link-local
140
- "fec0::/10", // site-local (deprecated)
141
- "ff00::/8", // multicast
142
- ])
143
-
144
- /** The IPv4 address embedded in a NAT64 or 6to4 address (or a mapped one), if it is one. */
145
- function embeddedV4(ip: Ip): Ip | undefined {
146
- if (ip.family !== 6) return undefined
147
- const b = ip.bytes
148
- const mapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff
149
- const nat64 = b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0)
150
- if (mapped || nat64) return { family: 4, bytes: b.slice(12, 16) }
151
- if (b[0] === 0x20 && b[1] === 0x02) return { family: 4, bytes: b.slice(2, 6) }
152
- return undefined
82
+ export function isPublicAddress(text: string): boolean {
83
+ const ip = parseIp(text)
84
+ return ip !== undefined && isPublic(ip)
153
85
  }
154
86
 
155
87
  function isPublic(ip: Ip): boolean {
156
- const inner = embeddedV4(ip)
157
- if (inner && !isPublic(inner)) return false
158
- return !(ip.family === 4 ? BLOCKED_V4 : BLOCKED_V6).some((cidr) => inCidr(ip, cidr))
88
+ if (ip instanceof ipaddr.IPv4) return ip.range() === "unicast"
89
+ const range = ip.range()
90
+ const inner = range === "6to4" ? v4From(ip.parts[1]!, ip.parts[2]!) : ip.match(NAT64) ? v4From(ip.parts[6]!, ip.parts[7]!) : undefined
91
+ if (inner) return isPublic(inner)
92
+ return range === "unicast" && !ip.match(IPV4_COMPATIBLE)
159
93
  }
160
94
 
161
95
  // ---- allowlist
162
96
 
163
97
  /** One `remoteImages.allow` entry: a host name (`images.internal`, or `*.internal` for subdomains), an IP address, or a CIDR range. */
164
- type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly cidr: Cidr }
98
+ type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly address: Ip; readonly bits: number }
165
99
 
166
100
  const HOSTNAME = /^(?:\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
167
101
 
168
102
  /** Undefined when `entry` is none of the three forms. */
169
103
  export function parseAllowEntry(entry: string): AllowEntry | undefined {
170
104
  const text = entry.trim().toLowerCase()
171
- const cidr = parseCidr(text)
172
- if (cidr) return { kind: "net", cidr }
105
+ const [address = "", length, extra] = text.split("/")
106
+ const ip = parseIp(address)
107
+ if (ip && extra === undefined) {
108
+ const max = ip instanceof ipaddr.IPv4 ? 32 : 128
109
+ if (length === undefined) return { kind: "net", address: ip, bits: max }
110
+ if (/^\d{1,3}$/.test(length) && Number(length) <= max) return { kind: "net", address: ip, bits: Number(length) }
111
+ }
173
112
  return HOSTNAME.test(text) && !text.includes("/") ? { kind: "host", name: text } : undefined
174
113
  }
175
114
 
@@ -190,11 +129,12 @@ function resolveAddresses(host: string, signal: AbortSignal): Promise<string[]>
190
129
  }
191
130
 
192
131
  /**
193
- * Resolve the URL's host and decide which address to connect to. Every address the name resolves
194
- * to has to be allowed (a name that answers with one public and one private address is refused),
195
- * so the pinned one cannot be a surprise. Throws the generic fetch error when anything is refused.
132
+ * Resolve the URL's host and return the addresses to connect to, in resolver order. Every address
133
+ * the name resolves to has to be allowed (a name that answers with one public and one private
134
+ * address is refused), so a pinned one cannot be a surprise. Throws the generic fetch error when
135
+ * anything is refused.
196
136
  */
197
- async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string> {
137
+ async function checkedAddresses(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string[]> {
198
138
  const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
199
139
  const literal = parseIp(host)
200
140
  const addresses = literal ? [host] : await resolveAddresses(host, signal)
@@ -202,14 +142,14 @@ async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: A
202
142
  const permitted = (address: string): boolean => {
203
143
  const ip = parseIp(address)
204
144
  if (!ip) return false
205
- return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inCidr(ip, entry.cidr))
145
+ return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inNet(ip, entry.address, entry.bits))
206
146
  }
207
147
  if (addresses.length === 0 || !addresses.every(permitted)) {
208
148
  // The log says which kind of refusal, never the host or the URL.
209
149
  log("media.remote_refused", { reason: addresses.length === 0 ? "no_address" : "address_not_allowed" })
210
150
  throw FETCH_FAILED()
211
151
  }
212
- return addresses[0]!
152
+ return addresses
213
153
  }
214
154
 
215
155
  /** GET `url` over a connection to `address` (already checked), keeping the URL's host for `Host` and TLS. No redirects are followed by the caller. */
@@ -239,6 +179,18 @@ function get(url: URL, address: string, signal: AbortSignal): Promise<IncomingMe
239
179
  })
240
180
  }
241
181
 
182
+ /** The first connection that succeeds, trying each checked address in order (#134); only a connection failure moves on, never a response. */
183
+ async function getFirst(url: URL, addresses: ReadonlyArray<string>, signal: AbortSignal): Promise<IncomingMessage> {
184
+ for (const address of addresses) {
185
+ try {
186
+ return await get(url, address, signal)
187
+ } catch {
188
+ if (signal.aborted) break
189
+ }
190
+ }
191
+ throw FETCH_FAILED()
192
+ }
193
+
242
194
  // ---- preparation
243
195
 
244
196
  async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteImages, budget: Budget): Promise<string> {
@@ -260,12 +212,10 @@ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteI
260
212
  throw FETCH_FAILED()
261
213
  }
262
214
  const allow = typeof remoteImages === "object" ? remoteImages.allow.flatMap((entry) => parseAllowEntry(entry) ?? []) : []
263
- const address = await pickAddress(target, allow, bounded).catch((error: unknown) => {
215
+ const addresses = await checkedAddresses(target, allow, bounded).catch((error: unknown) => {
264
216
  throw error instanceof ImageError ? error : FETCH_FAILED()
265
217
  })
266
- const response = await get(target, address, bounded).catch(() => {
267
- throw FETCH_FAILED()
268
- })
218
+ const response = await getFirst(target, addresses, bounded)
269
219
  // A redirect is never followed (it could point inside); anything but a plain success is a failed fetch.
270
220
  if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode > 299) {
271
221
  response.destroy()