pi-code 0.2.2 → 0.2.3

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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Web transport
3
+ *
4
+ * A single HTTP(S) request pinned to a caller-supplied DNS resolution. Global `fetch`
5
+ * resolves the hostname itself, independently of any prior guard, so a validate-then-fetch
6
+ * SSRF check has a time-of-check/time-of-use gap: a zero-TTL record can answer public to
7
+ * the guard and private to fetch's own lookup. `node:http`/`node:https` accept a `lookup`
8
+ * option, which is the seam that closes the gap: the socket connects to exactly the address
9
+ * the guard validated, while `servername` (SNI, certificate validation) and the `Host`
10
+ * header stay the real hostname, so virtual hosts and TLS still work.
11
+ */
12
+
13
+ import { request as httpRequest } from 'node:http'
14
+ import { request as httpsRequest } from 'node:https'
15
+ import type { LookupFunction } from 'node:net'
16
+ import { Readable } from 'node:stream'
17
+
18
+ export interface TransportOptions {
19
+ signal: AbortSignal
20
+ lookup: LookupFunction
21
+ userAgent: string
22
+ }
23
+
24
+ /** One request, no redirect following (the caller re-validates and re-pins per hop). */
25
+ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
26
+ const request = url.protocol === 'https:' ? httpsRequest : httpRequest
27
+ return new Promise((resolve, reject) => {
28
+ const req = request(
29
+ url,
30
+ {
31
+ method: 'GET',
32
+ headers: { 'User-Agent': opts.userAgent },
33
+ signal: opts.signal,
34
+ lookup: opts.lookup,
35
+ // servername is left to default to url.hostname, so SNI and certificate
36
+ // validation use the real host even though the socket connects to the pinned IP.
37
+ },
38
+ (res) => {
39
+ const headers = new Headers()
40
+ for (const [key, value] of Object.entries(res.headers)) {
41
+ if (typeof value === 'string') headers.set(key, value)
42
+ else if (Array.isArray(value)) headers.set(key, value.join(', '))
43
+ }
44
+ const body = Readable.toWeb(res) as ReadableStream<Uint8Array>
45
+ resolve(new Response(body, { status: res.statusCode ?? 0, headers }))
46
+ },
47
+ )
48
+ req.on('error', reject)
49
+ req.end()
50
+ })
51
+ }
package/extensions/web.ts CHANGED
@@ -6,10 +6,14 @@
6
6
  * Honors the local-only setup: no cloud accounts, plain HTTPS to public web.
7
7
  */
8
8
 
9
+ import type { LookupAddress } from 'node:dns'
9
10
  import { lookup } from 'node:dns/promises'
11
+ import type { LookupFunction } from 'node:net'
10
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
11
13
  import { Type } from 'typebox'
12
14
 
15
+ import { httpFetch } from './web-transport.js'
16
+
13
17
  const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
14
18
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
15
19
  const MAX_FETCH_CHARS = 30_000
@@ -128,15 +132,32 @@ export function isPrivateAddress(ip: string): boolean {
128
132
  return addr.includes(':') ? isPrivateIpv6(addr) : isPrivateIpv4(addr)
129
133
  }
130
134
 
131
- async function assertPublicHost(url: URL): Promise<void> {
135
+ /** A lookup that always yields `addresses`, so the socket cannot resolve the host again. */
136
+ export function pinnedLookup(addresses: LookupAddress[]): LookupFunction {
137
+ return (_hostname, options, callback) => {
138
+ const cb = (typeof options === 'function' ? options : callback) as (err: Error | null, address: unknown, family?: number) => void
139
+ if (typeof options !== 'function' && options.all) return cb(null, addresses)
140
+ const [first] = addresses
141
+ cb(null, first.address, first.family)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolve a host once, reject any private address, and return a lookup pinned to exactly
147
+ * those addresses. Passing that lookup to the transport is what closes the SSRF
148
+ * time-of-check/time-of-use gap: the connection reuses the validated resolution rather
149
+ * than issuing a second, unchecked DNS query that a rebinding record could answer privately.
150
+ */
151
+ async function resolveAndPin(url: URL): Promise<LookupFunction> {
132
152
  const host = url.hostname.replace(/^\[|\]$/g, '')
133
153
  const addresses = await lookup(host, { all: true, verbatim: true })
134
- // An empty list would leave nothing for the loop to reject, so the guard would pass
135
- // vacuously. Schemes without a host (data:, file:) reach here the same way.
154
+ // An empty list would leave nothing to reject, so the guard would pass vacuously.
155
+ // Schemes without a host (data:, file:) reach here the same way.
136
156
  if (addresses.length === 0) throw new Error(`${url.hostname || url.protocol} did not resolve to any address`)
137
157
  for (const { address } of addresses) {
138
158
  if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
139
159
  }
160
+ return pinnedLookup(addresses)
140
161
  }
141
162
 
142
163
  const MAX_REDIRECTS = 5
@@ -156,14 +177,15 @@ async function readCapped(response: Response): Promise<string> {
156
177
  return text.slice(0, MAX_RAW_CHARS)
157
178
  }
158
179
 
159
- async function fetchText(rawUrl: string): Promise<{ text: string; contentType: string }> {
180
+ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text: string; contentType: string }> {
160
181
  let url = new URL(rawUrl)
161
182
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
162
- await assertPublicHost(url)
163
- const response = await fetch(url, {
164
- headers: { 'User-Agent': USER_AGENT },
183
+ // Resolve, validate and pin per hop: a redirect target gets the same guarantee.
184
+ const lookup = await resolveAndPin(url)
185
+ const response = await transport(url, {
165
186
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
166
- redirect: 'manual',
187
+ lookup,
188
+ userAgent: USER_AGENT,
167
189
  })
168
190
  if (response.status >= 300 && response.status < 400) {
169
191
  const location = response.headers.get('location')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi-package"