@isomoes/iread 0.2.4 → 0.2.6
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 +20 -1
- package/dist/server/auth.js +18 -0
- package/dist/server/cli.js +8 -0
- package/dist/server/fetch-feed.js +58 -2
- package/dist/server/index.js +4 -0
- package/dist/server/proxy.js +114 -0
- package/dist/server/ssrf.js +65 -39
- package/dist/web/assets/{index-xsbnsoEj.js → index-CToy3o9l.js} +1 -1
- package/dist/web/assets/index-Cf03d0jp.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CMtWFJ_5.css +0 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-first three-pane UI, TypeScript end to end. You add feeds by URL, refresh them on demand, and read server-sanitized article bodies in a calm, terminal-quiet reading surface with light, dark, and system themes.
|
|
4
4
|
|
|
5
|
-
There is no
|
|
5
|
+
Optional token authentication supports private access over a public network. There is no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db`.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
@@ -21,6 +21,7 @@ Usage: iread [options]
|
|
|
21
21
|
|
|
22
22
|
Options:
|
|
23
23
|
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
24
|
+
--auth-token <token> Require a bearer token (default: $AUTH_TOKEN; unset disables auth)
|
|
24
25
|
--db <path> SQLite database file
|
|
25
26
|
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
26
27
|
--opml <path> OPML file auto-saved on every subscription change
|
|
@@ -50,6 +51,20 @@ Build the image from the checkout instead of pulling it with `docker compose up
|
|
|
50
51
|
|
|
51
52
|
`PORT` (default 8787), `HOST` (default all interfaces), `DB_PATH` (default `/data/iread.db`), and `OPML_PATH` (default `/data/feeds.opml`) are configurable via `-e`/`environment:`. With host networking, set `HOST=127.0.0.1` to restrict access to localhost; with bridge networking, leave `HOST` unset and restrict the published port instead. To store data in a host directory instead of a named volume, bind-mount it and make it writable by the image's unprivileged `node` user (uid 1000), e.g. `mkdir iread-data && sudo chown 1000:1000 iread-data` then `-v "$PWD/iread-data:/data"`.
|
|
52
53
|
|
|
54
|
+
## Public network access
|
|
55
|
+
|
|
56
|
+
Authentication is **disabled by default**. Set `AUTH_TOKEN` to a long, random secret to protect every API endpoint:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
AUTH_TOKEN='your-long-random-secret' npx @isomoes/iread
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
You can also use `--auth-token <token>`. Environment variables are preferable to putting secrets in command-line arguments. For Compose, set `AUTH_TOKEN` in your environment or Compose `.env` file before `docker compose up -d`. For `docker run`, pass `-e AUTH_TOKEN='your-long-random-secret'`.
|
|
63
|
+
|
|
64
|
+
The web UI prompts for the token and remembers it for the current browser tab using sessionStorage. API clients must send `Authorization: Bearer <token>`, including for OPML export. The static UI remains public but no feed or article data is accessible without the token. Changing the server token requires restarting the server; clients with the old token must sign in again.
|
|
65
|
+
|
|
66
|
+
Use HTTPS through a reverse proxy for public access, and configure proxy-side rate limiting against token guessing. Never expose an instance without a token unless access is restricted by another trusted layer. `AUTH_TOKEN` unset or empty preserves the existing unauthenticated behavior. `config/.env.example` documents the settings; the server reads environment variables, not that file automatically.
|
|
67
|
+
|
|
53
68
|
## Features
|
|
54
69
|
|
|
55
70
|
- Add a feed by URL (title resolved from feed metadata), delete a feed.
|
|
@@ -149,3 +164,7 @@ pnpm start
|
|
|
149
164
|
Open http://localhost:8787
|
|
150
165
|
|
|
151
166
|
`PORT` (default 8787), `HOST` (default all interfaces; set `127.0.0.1` for localhost only), `DB_PATH` (default `~/.config/iread/iread.db`), and `OPML_PATH` (default `feeds.opml` next to the database; set empty to disable the auto-saved mirror) are read from the environment. See `config/.env.example`.
|
|
167
|
+
|
|
168
|
+
Feeds are fetched directly by default. To go through an outbound HTTP proxy, set `HTTPS_PROXY` / `HTTP_PROXY` (an `http://host:port` URL, optionally with `user:pass@`) and optionally `NO_PROXY` (comma-separated hosts/domains to bypass). HTTPS feeds are tunnelled with `CONNECT`. Note that the SSRF guard still validates the feed host's DNS answer locally, but with a proxy the proxy does the final connect, so DNS-rebinding pinning cannot apply.
|
|
169
|
+
|
|
170
|
+
Each fetch attempt has a 30-second timeout. Transient network failures and HTTP 408/429/500/502/503/504 are retried once, normally after one second. A valid `Retry-After` is honored up to five seconds; longer delays are not retried automatically. Permanent HTTP errors, unsafe URLs, and oversized feeds are not retried. Run fetch regression tests with `pnpm test`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createMiddleware } from 'hono/factory';
|
|
3
|
+
export function tokenAuth(token) {
|
|
4
|
+
const expected = token ? createHash('sha256').update(token).digest() : undefined;
|
|
5
|
+
return createMiddleware(async (c, next) => {
|
|
6
|
+
c.header('Cache-Control', 'no-store');
|
|
7
|
+
if (expected) {
|
|
8
|
+
const authorization = c.req.header('Authorization') ?? '';
|
|
9
|
+
const match = /^Bearer ([^\s]+)$/i.exec(authorization);
|
|
10
|
+
const supplied = createHash('sha256').update(match?.[1] ?? '').digest();
|
|
11
|
+
if (!match || !timingSafeEqual(expected, supplied)) {
|
|
12
|
+
c.header('WWW-Authenticate', 'Bearer realm="iread"');
|
|
13
|
+
return c.json({ error: { message: 'Authentication required', code: 'UNAUTHORIZED' } }, 401);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
await next();
|
|
17
|
+
});
|
|
18
|
+
}
|
package/dist/server/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ Usage: iread [options]
|
|
|
28
28
|
|
|
29
29
|
Options:
|
|
30
30
|
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
31
|
+
--auth-token <token> Require a bearer token (default: $AUTH_TOKEN; unset disables auth)
|
|
31
32
|
--db <path> SQLite database file
|
|
32
33
|
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
33
34
|
--opml <path> OPML file auto-saved on every subscription change
|
|
@@ -70,6 +71,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
70
71
|
process.env.PORT = value;
|
|
71
72
|
break;
|
|
72
73
|
}
|
|
74
|
+
case '--auth-token': {
|
|
75
|
+
const value = args[++i];
|
|
76
|
+
if (!value || /\s/.test(value))
|
|
77
|
+
fail('--auth-token requires a non-empty token without whitespace');
|
|
78
|
+
process.env.AUTH_TOKEN = value;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
73
81
|
case '--db': {
|
|
74
82
|
const value = args[++i];
|
|
75
83
|
if (!value)
|
|
@@ -3,9 +3,36 @@
|
|
|
3
3
|
// The body is read incrementally and the request is aborted the moment cumulative
|
|
4
4
|
// bytes exceed MAX_BYTES; the whole response is never buffered before the check.
|
|
5
5
|
import { fetchWithGuardedRedirects } from './ssrf.js';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
7
|
const USER_AGENT = 'iread/1.0 (+local RSS reader)';
|
|
7
|
-
const FETCH_TIMEOUT_MS =
|
|
8
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
8
9
|
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
10
|
+
const RETRY_CODES = new Set([
|
|
11
|
+
'ECONNRESET',
|
|
12
|
+
'ECONNREFUSED',
|
|
13
|
+
'ETIMEDOUT',
|
|
14
|
+
'EAI_AGAIN',
|
|
15
|
+
'ENETUNREACH',
|
|
16
|
+
'EHOSTUNREACH',
|
|
17
|
+
'EPIPE',
|
|
18
|
+
'ERR_STREAM_PREMATURE_CLOSE',
|
|
19
|
+
]);
|
|
20
|
+
class RetryableFetchError extends Error {
|
|
21
|
+
retryDelay;
|
|
22
|
+
constructor(message, retryDelay = 1000) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.retryDelay = retryDelay;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isTransient(err) {
|
|
28
|
+
if (err instanceof RetryableFetchError)
|
|
29
|
+
return true;
|
|
30
|
+
if (err instanceof AggregateError)
|
|
31
|
+
return err.errors.length > 0 && err.errors.every(isTransient);
|
|
32
|
+
if (!(err instanceof Error))
|
|
33
|
+
return false;
|
|
34
|
+
return RETRY_CODES.has(err.code ?? '') || isTransient(err.cause);
|
|
35
|
+
}
|
|
9
36
|
function concat(chunks, total) {
|
|
10
37
|
const out = new Uint8Array(total);
|
|
11
38
|
let offset = 0;
|
|
@@ -16,6 +43,18 @@ function concat(chunks, total) {
|
|
|
16
43
|
return out;
|
|
17
44
|
}
|
|
18
45
|
export async function fetchFeed(input) {
|
|
46
|
+
for (let attempt = 0;; attempt++) {
|
|
47
|
+
try {
|
|
48
|
+
return await fetchAttempt(input);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (attempt >= 1 || !isTransient(err))
|
|
52
|
+
throw err;
|
|
53
|
+
await delay(err instanceof RetryableFetchError ? err.retryDelay : 1000);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function fetchAttempt(input) {
|
|
19
58
|
const ctrl = new AbortController();
|
|
20
59
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
21
60
|
try {
|
|
@@ -39,7 +78,18 @@ export async function fetchFeed(input) {
|
|
|
39
78
|
}
|
|
40
79
|
if (!res.ok) {
|
|
41
80
|
await res.body?.cancel().catch(() => { });
|
|
42
|
-
|
|
81
|
+
const message = `HTTP ${res.status} ${res.statusText}`;
|
|
82
|
+
const retryAfter = res.headers.get('retry-after');
|
|
83
|
+
const retryDelay = retryAfter === null
|
|
84
|
+
? 1000
|
|
85
|
+
: /^\d+$/.test(retryAfter)
|
|
86
|
+
? Number(retryAfter) * 1000
|
|
87
|
+
: Math.max(0, Date.parse(retryAfter) - Date.now());
|
|
88
|
+
if ([408, 429, 500, 502, 503, 504].includes(res.status) &&
|
|
89
|
+
Number.isFinite(retryDelay) && retryDelay <= 5000) {
|
|
90
|
+
throw new RetryableFetchError(message, retryDelay);
|
|
91
|
+
}
|
|
92
|
+
throw new Error(message);
|
|
43
93
|
}
|
|
44
94
|
// Fast reject on advertised Content-Length (untrusted but cheap).
|
|
45
95
|
const cl = Number(res.headers.get('content-length'));
|
|
@@ -76,6 +126,12 @@ export async function fetchFeed(input) {
|
|
|
76
126
|
lastModified: res.headers.get('last-modified'),
|
|
77
127
|
};
|
|
78
128
|
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (ctrl.signal.aborted && err instanceof Error && err.name === 'AbortError') {
|
|
131
|
+
throw new RetryableFetchError(`Feed request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
79
135
|
finally {
|
|
80
136
|
clearTimeout(timer);
|
|
81
137
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { serveStatic } from '@hono/node-server/serve-static';
|
|
|
12
12
|
import { feeds } from './routes/feeds.js';
|
|
13
13
|
import { items } from './routes/items.js';
|
|
14
14
|
import { opml } from './routes/opml.js';
|
|
15
|
+
import { tokenAuth } from './auth.js';
|
|
15
16
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
16
17
|
const HOST = process.env.HOST || undefined;
|
|
17
18
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
@@ -21,6 +22,9 @@ import './db.js';
|
|
|
21
22
|
import { syncOpmlMirror } from './feed-service.js';
|
|
22
23
|
syncOpmlMirror();
|
|
23
24
|
const app = new Hono();
|
|
25
|
+
app.use('/api/*', tokenAuth(process.env.AUTH_TOKEN));
|
|
26
|
+
app.use('/api', tokenAuth(process.env.AUTH_TOKEN));
|
|
27
|
+
app.get('/api/auth', (c) => c.body(null, 204));
|
|
24
28
|
// --- API routes (registered FIRST) -----------------------------------------
|
|
25
29
|
const api = new Hono();
|
|
26
30
|
api.route('/feeds', feeds);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/server/proxy.ts
|
|
2
|
+
// Optional outbound HTTP(S) proxy support for feed fetching, driven by the usual
|
|
3
|
+
// HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables (either case).
|
|
4
|
+
// Only `http://` proxy URLs are supported (HTTPS targets are tunnelled via CONNECT).
|
|
5
|
+
import { request as httpRequest } from 'node:http';
|
|
6
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
7
|
+
function env(name) {
|
|
8
|
+
return process.env[name] ?? process.env[name.toLowerCase()] ?? undefined;
|
|
9
|
+
}
|
|
10
|
+
function bypassed(host, port, noProxy) {
|
|
11
|
+
const h = host.toLowerCase();
|
|
12
|
+
for (const raw of noProxy.split(',')) {
|
|
13
|
+
let entry = raw.trim().toLowerCase();
|
|
14
|
+
if (!entry)
|
|
15
|
+
continue;
|
|
16
|
+
if (entry === '*')
|
|
17
|
+
return true;
|
|
18
|
+
const colon = entry.lastIndexOf(':');
|
|
19
|
+
if (colon > 0 && !entry.endsWith(']') && /^\d+$/.test(entry.slice(colon + 1))) {
|
|
20
|
+
if (entry.slice(colon + 1) !== port)
|
|
21
|
+
continue;
|
|
22
|
+
entry = entry.slice(0, colon);
|
|
23
|
+
}
|
|
24
|
+
entry = entry.replace(/^\*?\./, '');
|
|
25
|
+
if (h === entry || h.endsWith(`.${entry}`))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/** Proxy to use for `target`, or null for a direct connection. */
|
|
31
|
+
export function proxyFor(target) {
|
|
32
|
+
const isHttps = target.protocol === 'https:';
|
|
33
|
+
const value = isHttps ? (env('HTTPS_PROXY') ?? env('HTTP_PROXY')) : env('HTTP_PROXY');
|
|
34
|
+
if (!value)
|
|
35
|
+
return null;
|
|
36
|
+
const port = target.port || (isHttps ? '443' : '80');
|
|
37
|
+
const host = target.hostname.replace(/^\[|\]$/g, '');
|
|
38
|
+
if (bypassed(host, port, env('NO_PROXY') ?? ''))
|
|
39
|
+
return null;
|
|
40
|
+
let p;
|
|
41
|
+
try {
|
|
42
|
+
p = new URL(value.includes('://') ? value : `http://${value}`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (p.protocol !== 'http:')
|
|
48
|
+
return null;
|
|
49
|
+
const auth = p.username
|
|
50
|
+
? `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`
|
|
51
|
+
: null;
|
|
52
|
+
return { host: p.hostname.replace(/^\[|\]$/g, ''), port: Number(p.port || 80), auth };
|
|
53
|
+
}
|
|
54
|
+
function proxyAuthHeader(proxy) {
|
|
55
|
+
return proxy.auth
|
|
56
|
+
? { 'Proxy-Authorization': `Basic ${Buffer.from(proxy.auth).toString('base64')}` }
|
|
57
|
+
: {};
|
|
58
|
+
}
|
|
59
|
+
/** Headers to add when sending an absolute-URI request to the proxy (http targets). */
|
|
60
|
+
export function plainProxyHeaders(proxy) {
|
|
61
|
+
return proxyAuthHeader(proxy);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Open a CONNECT tunnel through `proxy` to target host:port and wrap it in TLS
|
|
65
|
+
* (SNI = target hostname). Honors `signal` for cancellation.
|
|
66
|
+
*/
|
|
67
|
+
export function tunnelTls(proxy, target, signal) {
|
|
68
|
+
const port = target.port || '443';
|
|
69
|
+
const hostname = target.hostname.replace(/^\[|\]$/g, '');
|
|
70
|
+
const authority = `${target.hostname}:${port}`;
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const req = httpRequest({
|
|
73
|
+
host: proxy.host,
|
|
74
|
+
port: proxy.port,
|
|
75
|
+
method: 'CONNECT',
|
|
76
|
+
path: authority,
|
|
77
|
+
headers: { Host: authority, ...proxyAuthHeader(proxy) },
|
|
78
|
+
});
|
|
79
|
+
let tunnel;
|
|
80
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
81
|
+
const fail = (err) => {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(err);
|
|
84
|
+
};
|
|
85
|
+
const abort = () => {
|
|
86
|
+
const err = new DOMException('Aborted', 'AbortError');
|
|
87
|
+
tunnel?.destroy(err);
|
|
88
|
+
req.destroy(err);
|
|
89
|
+
fail(err);
|
|
90
|
+
};
|
|
91
|
+
if (signal) {
|
|
92
|
+
if (signal.aborted)
|
|
93
|
+
abort();
|
|
94
|
+
else
|
|
95
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
96
|
+
}
|
|
97
|
+
req.once('connect', (res, socket) => {
|
|
98
|
+
if (res.statusCode !== 200) {
|
|
99
|
+
socket.destroy();
|
|
100
|
+
fail(new Error(`proxy CONNECT failed: HTTP ${res.statusCode ?? 0}`));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const tls = tlsConnect({ socket, servername: hostname });
|
|
104
|
+
tunnel = tls;
|
|
105
|
+
tls.once('secureConnect', () => {
|
|
106
|
+
cleanup();
|
|
107
|
+
resolve(tls);
|
|
108
|
+
});
|
|
109
|
+
tls.once('error', fail);
|
|
110
|
+
});
|
|
111
|
+
req.once('error', fail);
|
|
112
|
+
req.end();
|
|
113
|
+
});
|
|
114
|
+
}
|
package/dist/server/ssrf.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isIP } from 'node:net';
|
|
|
7
7
|
import { request as httpRequest } from 'node:http';
|
|
8
8
|
import { request as httpsRequest } from 'node:https';
|
|
9
9
|
import { Readable } from 'node:stream';
|
|
10
|
+
import { plainProxyHeaders, proxyFor, tunnelTls } from './proxy.js';
|
|
10
11
|
const MAX_REDIRECTS = 5;
|
|
11
12
|
export class SsrfError extends Error {
|
|
12
13
|
name = 'SsrfError';
|
|
@@ -190,48 +191,73 @@ function pinnedFetch(url, validated, init) {
|
|
|
190
191
|
const isHttps = parsed.protocol === 'https:';
|
|
191
192
|
const requestFn = isHttps ? httpsRequest : httpRequest;
|
|
192
193
|
const signal = init.signal ?? undefined;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
194
|
+
const proxy = proxyFor(parsed);
|
|
195
|
+
return (async () => {
|
|
196
|
+
// Via a proxy the destination was still validated (assertSafeFeedUrl) but the
|
|
197
|
+
// proxy performs the final resolve+connect, so IP pinning cannot apply.
|
|
198
|
+
let tunnel;
|
|
199
|
+
if (proxy && isHttps)
|
|
200
|
+
tunnel = await tunnelTls(proxy, parsed, signal);
|
|
201
|
+
return send(tunnel);
|
|
202
|
+
})();
|
|
203
|
+
function send(tunnel) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const headers = headersToObject(init.headers);
|
|
206
|
+
let target = parsed;
|
|
207
|
+
let extra = { lookup: pinnedLookup(validated) };
|
|
208
|
+
let fn = requestFn;
|
|
209
|
+
if (tunnel) {
|
|
210
|
+
extra = { createConnection: () => tunnel };
|
|
209
211
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
:
|
|
216
|
-
resolve(new Response(body, {
|
|
217
|
-
status,
|
|
218
|
-
statusText: msg.statusMessage ?? '',
|
|
219
|
-
headers,
|
|
220
|
-
}));
|
|
221
|
-
});
|
|
222
|
-
if (signal) {
|
|
223
|
-
if (signal.aborted) {
|
|
224
|
-
req.destroy(new DOMException('Aborted', 'AbortError'));
|
|
212
|
+
else if (proxy) {
|
|
213
|
+
// Plain-http target: absolute-URI request to the proxy.
|
|
214
|
+
fn = httpRequest;
|
|
215
|
+
target = new URL(`http://${proxy.host.includes(':') ? `[${proxy.host}]` : proxy.host}:${proxy.port}`);
|
|
216
|
+
extra = { path: parsed.href, setHost: false };
|
|
217
|
+
Object.assign(headers, { host: parsed.host }, plainProxyHeaders(proxy));
|
|
225
218
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
219
|
+
const req = fn(target, {
|
|
220
|
+
method: (init.method ?? 'GET').toUpperCase(),
|
|
221
|
+
headers,
|
|
222
|
+
...extra,
|
|
223
|
+
// servername defaults to the URL hostname for https, preserving SNI.
|
|
224
|
+
}, (msg) => {
|
|
225
|
+
const headers = new Headers();
|
|
226
|
+
for (const [k, v] of Object.entries(msg.headers)) {
|
|
227
|
+
if (v === undefined)
|
|
228
|
+
continue;
|
|
229
|
+
if (Array.isArray(v))
|
|
230
|
+
for (const one of v)
|
|
231
|
+
headers.append(k, one);
|
|
232
|
+
else
|
|
233
|
+
headers.set(k, v);
|
|
234
|
+
}
|
|
235
|
+
const status = msg.statusCode ?? 0;
|
|
236
|
+
// 204/304 must not carry a body in a Response.
|
|
237
|
+
const nullBody = status === 204 || status === 304;
|
|
238
|
+
const body = nullBody
|
|
239
|
+
? null
|
|
240
|
+
: Readable.toWeb(msg);
|
|
241
|
+
resolve(new Response(body, {
|
|
242
|
+
status,
|
|
243
|
+
statusText: msg.statusMessage ?? '',
|
|
244
|
+
headers,
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
if (signal) {
|
|
248
|
+
if (signal.aborted) {
|
|
249
|
+
req.destroy(new DOMException('Aborted', 'AbortError'));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
signal.addEventListener('abort', () => req.destroy(new DOMException('Aborted', 'AbortError')), {
|
|
253
|
+
once: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
230
256
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
257
|
+
req.on('error', reject);
|
|
258
|
+
req.end();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
235
261
|
}
|
|
236
262
|
/**
|
|
237
263
|
* fetch() that follows redirects manually (max 5 hops), re-running the SSRF
|