opencode-plugboard 0.5.0 → 0.7.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.
@@ -4,20 +4,39 @@
4
4
  * Prompt files must be inline `data:` URLs. http(s) images are fetched once, only when the
5
5
  * `remoteImages` option is on, with no redirects, a 20 s timeout and a 20 MB cap. All images of
6
6
  * one request together may hold at most 50 MB (`images_too_large`), data URLs and fetched ones alike.
7
+ *
8
+ * A remote fetch is a request the server makes on a caller's behalf, so it must not reach the
9
+ * server's own network (#84): the host is resolved first, every address it resolves to must be a
10
+ * public one (loopback, private, link-local, CGNAT, ULA, multicast, reserved and mapped or
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.
7
15
  */
8
16
 
17
+ import { lookup } from "node:dns/promises"
18
+ import { request as httpRequest, type IncomingMessage } from "node:http"
19
+ import { request as httpsRequest } from "node:https"
20
+ import { isIP } from "node:net"
9
21
  import type { PlugRequest } from "../contract.js"
22
+ import { log } from "../log.js"
10
23
 
11
24
  const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024
12
25
  /** Aggregate size of the prepared data URLs of one request. */
13
26
  const MAX_TOTAL_IMAGE_BYTES = 50 * 1024 * 1024
27
+ const FETCH_TIMEOUT_MS = 20_000
14
28
  const TOO_MANY = () => new ImageError("images_too_large", "The request's images are larger than 50 MB in total")
29
+ /** The one message for every failed fetch (blocked address, DNS, refused, timeout, bad status): no oracle for the server's network. */
30
+ const FETCH_FAILED = () => new ImageError("image_fetch_failed", "Could not fetch image")
15
31
 
16
32
  /** What one request's images may still use, in data URL characters (== bytes held). */
17
33
  interface Budget {
18
34
  remaining: number
19
35
  }
20
36
 
37
+ /** The `remoteImages` option as the session caller receives it: off, on (public addresses only), or on with an allowlist. */
38
+ export type RemoteImages = boolean | { readonly allow: ReadonlyArray<string> }
39
+
21
40
  /** A caller image that cannot be used; `code` becomes the error code of the 400 response. */
22
41
  export class ImageError extends Error {
23
42
  constructor(
@@ -28,41 +47,255 @@ export class ImageError extends Error {
28
47
  }
29
48
  }
30
49
 
31
- async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean, budget: Budget): Promise<string> {
50
+ // ---- addresses
51
+
52
+ interface Ip {
53
+ readonly family: 4 | 6
54
+ readonly bytes: ReadonlyArray<number>
55
+ }
56
+
57
+ /** Dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone) to bytes. */
58
+ 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
92
+ }
93
+ return true
94
+ }
95
+
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
+ }
105
+
106
+ const cidrs = (list: string[]): Cidr[] => list.map((entry) => parseCidr(entry)!)
107
+
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
+ ])
125
+
126
+ /**
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).
130
+ */
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
153
+ }
154
+
155
+ 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))
159
+ }
160
+
161
+ // ---- allowlist
162
+
163
+ /** 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 }
165
+
166
+ const HOSTNAME = /^(?:\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
167
+
168
+ /** Undefined when `entry` is none of the three forms. */
169
+ export function parseAllowEntry(entry: string): AllowEntry | undefined {
170
+ const text = entry.trim().toLowerCase()
171
+ const cidr = parseCidr(text)
172
+ if (cidr) return { kind: "net", cidr }
173
+ return HOSTNAME.test(text) && !text.includes("/") ? { kind: "host", name: text } : undefined
174
+ }
175
+
176
+ const hostMatches = (entry: { readonly name: string }, host: string): boolean =>
177
+ entry.name.startsWith("*.") ? host.endsWith(entry.name.slice(1)) : host === entry.name
178
+
179
+ /** All addresses of `host`, in resolver order; rejects when `signal` aborts (the lookup itself cannot be cancelled). */
180
+ function resolveAddresses(host: string, signal: AbortSignal): Promise<string[]> {
181
+ return new Promise((done, fail) => {
182
+ const onAbort = () => fail(signal.reason)
183
+ if (signal.aborted) return onAbort()
184
+ signal.addEventListener("abort", onAbort, { once: true })
185
+ lookup(host, { all: true, verbatim: true }).then(
186
+ (found) => (signal.removeEventListener("abort", onAbort), done(found.map((entry) => entry.address))),
187
+ (error) => (signal.removeEventListener("abort", onAbort), fail(error)),
188
+ )
189
+ })
190
+ }
191
+
192
+ /**
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.
196
+ */
197
+ async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string> {
198
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
199
+ const literal = parseIp(host)
200
+ const addresses = literal ? [host] : await resolveAddresses(host, signal)
201
+ const nameAllowed = allow.some((entry) => entry.kind === "host" && hostMatches(entry, host))
202
+ const permitted = (address: string): boolean => {
203
+ const ip = parseIp(address)
204
+ if (!ip) return false
205
+ return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inCidr(ip, entry.cidr))
206
+ }
207
+ if (addresses.length === 0 || !addresses.every(permitted)) {
208
+ // The log says which kind of refusal, never the host or the URL.
209
+ log("media.remote_refused", { reason: addresses.length === 0 ? "no_address" : "address_not_allowed" })
210
+ throw FETCH_FAILED()
211
+ }
212
+ return addresses[0]!
213
+ }
214
+
215
+ /** 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. */
216
+ function get(url: URL, address: string, signal: AbortSignal): Promise<IncomingMessage> {
217
+ return new Promise((resolve, reject) => {
218
+ const secure = url.protocol === "https:"
219
+ const name = url.hostname.replace(/^\[|\]$/g, "")
220
+ const request = (secure ? httpsRequest : httpRequest)(
221
+ {
222
+ host: address,
223
+ port: url.port === "" ? (secure ? 443 : 80) : Number(url.port),
224
+ path: `${url.pathname}${url.search}`,
225
+ method: "GET",
226
+ agent: false,
227
+ headers: { host: url.host, accept: "image/*", "user-agent": "opencode-plugboard" },
228
+ // Certificate checks use the URL's host name, not the pinned address.
229
+ ...(secure && isIP(name) === 0 ? { servername: name } : {}),
230
+ },
231
+ resolve,
232
+ )
233
+ request.on("error", reject)
234
+ const abort = () => request.destroy(new Error("aborted"))
235
+ request.on("close", () => signal.removeEventListener("abort", abort))
236
+ if (signal.aborted) return abort()
237
+ signal.addEventListener("abort", abort, { once: true })
238
+ request.end()
239
+ })
240
+ }
241
+
242
+ // ---- preparation
243
+
244
+ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteImages, budget: Budget): Promise<string> {
32
245
  if (url.startsWith("data:")) {
33
246
  if (url.length > budget.remaining) throw TOO_MANY()
34
247
  return url
35
248
  }
36
249
  if (!/^https?:\/\//i.test(url)) throw new ImageError("unsupported_image_url", "Unsupported image URL scheme; use a data: URL or an http(s) URL")
37
- if (!remoteImages)
250
+ if (remoteImages === false)
38
251
  throw new ImageError("remote_images_disabled", "Remote image URLs are disabled; send a data: URL or set the plugboard `remoteImages` option")
39
- // Messages never carry the URL or the fetch error text: both can hold credentials in the query string.
40
- const response = await fetch(url, { redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]) }).catch((error: unknown) => {
41
- const reason = error instanceof Error && error.name === "TimeoutError" ? "timed out" : "network error"
42
- throw new ImageError("image_fetch_failed", `Could not fetch image (${reason})`)
252
+
253
+ // Messages never carry the URL, the status, the content type or the fetch error text: they can hold
254
+ // credentials in the query string or describe the server's network to the caller.
255
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
256
+ let target: URL
257
+ try {
258
+ target = new URL(url)
259
+ } catch {
260
+ throw FETCH_FAILED()
261
+ }
262
+ const allow = typeof remoteImages === "object" ? remoteImages.allow.flatMap((entry) => parseAllowEntry(entry) ?? []) : []
263
+ const address = await pickAddress(target, allow, bounded).catch((error: unknown) => {
264
+ throw error instanceof ImageError ? error : FETCH_FAILED()
265
+ })
266
+ const response = await get(target, address, bounded).catch(() => {
267
+ throw FETCH_FAILED()
43
268
  })
44
- if (!response.ok) throw new ImageError("image_fetch_failed", `Could not fetch image (HTTP ${response.status})`)
45
- const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? ""
46
- if (!mediaType.startsWith("image/")) throw new ImageError("invalid_image", `URL did not return an image (content-type: ${mediaType || "none"})`)
47
- const declared = Number(response.headers.get("content-length"))
269
+ // A redirect is never followed (it could point inside); anything but a plain success is a failed fetch.
270
+ if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode > 299) {
271
+ response.destroy()
272
+ throw FETCH_FAILED()
273
+ }
274
+ const mediaType = (response.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() ?? ""
275
+ if (!mediaType.startsWith("image/")) {
276
+ response.destroy()
277
+ throw new ImageError("invalid_image", "URL did not return an image")
278
+ }
279
+ const declared = Number(response.headers["content-length"])
48
280
  // The data URL is 4/3 of the raw size plus a short prefix: stop reading once it cannot fit the budget.
49
281
  const budgetRaw = Math.floor((budget.remaining * 3) / 4)
50
282
  const limit = Math.min(MAX_REMOTE_IMAGE_BYTES, budgetRaw)
51
283
  const tooLarge = () => (limit < MAX_REMOTE_IMAGE_BYTES ? TOO_MANY() : new ImageError("image_too_large", "Image is larger than 20 MB"))
52
- if (Number.isFinite(declared) && declared > limit) throw tooLarge()
53
- const chunks: Uint8Array[] = []
284
+ if (Number.isFinite(declared) && declared > limit) {
285
+ response.destroy()
286
+ throw tooLarge()
287
+ }
288
+ const chunks: Buffer[] = []
54
289
  let size = 0
55
- const reader = response.body?.getReader()
56
- if (!reader) throw new ImageError("invalid_image", "Image response had no body")
57
- while (true) {
58
- const { done, value } = await reader.read()
59
- if (done) break
60
- size += value.byteLength
61
- if (size > limit) {
62
- void reader.cancel().catch(() => {})
63
- throw tooLarge()
290
+ try {
291
+ for await (const chunk of response as AsyncIterable<Buffer>) {
292
+ size += chunk.byteLength
293
+ if (size > limit) throw tooLarge()
294
+ chunks.push(chunk)
64
295
  }
65
- chunks.push(value)
296
+ } catch (error) {
297
+ response.destroy()
298
+ throw error instanceof ImageError ? error : FETCH_FAILED()
66
299
  }
67
300
  const dataUrl = `data:${mediaType};base64,${Buffer.concat(chunks).toString("base64")}`
68
301
  if (dataUrl.length > budget.remaining) throw TOO_MANY()
@@ -73,7 +306,7 @@ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean
73
306
  export async function prepareImages(
74
307
  request: PlugRequest,
75
308
  signal: AbortSignal,
76
- remoteImages: boolean,
309
+ remoteImages: RemoteImages,
77
310
  ): Promise<Array<{ uri: string; name: string }>> {
78
311
  const images: Array<{ uri: string; name: string }> = []
79
312
  const budget: Budget = { remaining: MAX_TOTAL_IMAGE_BYTES }