javascript-solid-server 0.0.171 → 0.0.172
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/bin/jss.js +10 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +27 -3
- package/src/config.js +19 -1
- package/src/handlers/cors-proxy.js +423 -0
- package/src/server.js +107 -1
package/bin/jss.js
CHANGED
|
@@ -105,6 +105,11 @@ program
|
|
|
105
105
|
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
106
106
|
.option('--git', 'Enable Git HTTP backend (clone/push support)')
|
|
107
107
|
.option('--no-git', 'Disable Git HTTP backend')
|
|
108
|
+
.option('--cors-proxy', 'Enable CORS proxy at /proxy?url=... for browser apps (WAC-gated)')
|
|
109
|
+
.option('--no-cors-proxy', 'Disable CORS proxy')
|
|
110
|
+
.option('--cors-proxy-max-bytes <n>', 'CORS proxy upstream response size cap (default 50MB)', parseInt)
|
|
111
|
+
.option('--cors-proxy-timeout-ms <ms>', 'CORS proxy upstream request timeout (default 30s)', parseInt)
|
|
112
|
+
.option('--cors-proxy-max-redirects <n>', 'CORS proxy max redirect hops, each re-validated (default 5)', parseInt)
|
|
108
113
|
.option('--nostr', 'Enable Nostr relay')
|
|
109
114
|
.option('--no-nostr', 'Disable Nostr relay')
|
|
110
115
|
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
|
|
@@ -191,6 +196,10 @@ program
|
|
|
191
196
|
mashlibVersion: config.mashlibVersion,
|
|
192
197
|
mashlibModule: config.mashlibModule,
|
|
193
198
|
git: config.git,
|
|
199
|
+
corsProxy: config.corsProxy,
|
|
200
|
+
corsProxyMaxBytes: config.corsProxyMaxBytes,
|
|
201
|
+
corsProxyTimeoutMs: config.corsProxyTimeoutMs,
|
|
202
|
+
corsProxyMaxRedirects: config.corsProxyMaxRedirects,
|
|
194
203
|
nostr: config.nostr,
|
|
195
204
|
nostrPath: config.nostrPath,
|
|
196
205
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
@@ -242,6 +251,7 @@ program
|
|
|
242
251
|
}
|
|
243
252
|
if (config.mashlibModule) console.log(` Mashlib module: ${config.mashlibModule}`);
|
|
244
253
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
254
|
+
if (config.corsProxy) console.log(' CORS proxy: enabled (/proxy?url=..., WAC-gated)');
|
|
245
255
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
246
256
|
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
|
247
257
|
if (config.terminal) console.log(' Terminal: enabled (/.terminal)');
|
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -48,8 +48,28 @@ export function buildResourceUrl(request, urlPath) {
|
|
|
48
48
|
* @param {object} request - Fastify request
|
|
49
49
|
* @param {object} reply - Fastify reply
|
|
50
50
|
* @param {object} options - Optional settings
|
|
51
|
-
* @param {string} options.requiredMode - Override the required access mode
|
|
52
|
-
*
|
|
51
|
+
* @param {string} [options.requiredMode] - Override the required access mode
|
|
52
|
+
* (e.g., 'Write' for git push). Defaults to getRequiredMode(method).
|
|
53
|
+
* @param {boolean} [options.skipParentForMissing] - When true, skip the
|
|
54
|
+
* "non-existent resource + write method → check parent container"
|
|
55
|
+
* fallback. Used by virtual endpoints (e.g. `/proxy` in #378) that have
|
|
56
|
+
* no backing storage but still want WAC checked against the URL itself.
|
|
57
|
+
* Without this flag, POST/PUT/PATCH on a missing resource is authorized
|
|
58
|
+
* against the parent (e.g. `/proxy` falls back to `/`), which is too
|
|
59
|
+
* permissive for endpoints whose ACL is meant to live at that path.
|
|
60
|
+
* @returns {Promise<{
|
|
61
|
+
* authorized: boolean,
|
|
62
|
+
* webId: string|null,
|
|
63
|
+
* wacAllow: string,
|
|
64
|
+
* authError: string|null,
|
|
65
|
+
* paymentRequired?: object,
|
|
66
|
+
* paid?: number,
|
|
67
|
+
* balance?: number,
|
|
68
|
+
* currency?: string
|
|
69
|
+
* }>}
|
|
70
|
+
* `paid` is the cost actually debited (number, not boolean) — see
|
|
71
|
+
* checkAccess() in src/wac/checker.js:189; callers stringify it for
|
|
72
|
+
* the X-Cost response header.
|
|
53
73
|
*/
|
|
54
74
|
export async function authorize(request, reply, options = {}) {
|
|
55
75
|
const urlPath = request.url.split('?')[0];
|
|
@@ -98,7 +118,7 @@ export async function authorize(request, reply, options = {}) {
|
|
|
98
118
|
let checkUrl = resourceUrl;
|
|
99
119
|
let checkIsContainer = isContainer;
|
|
100
120
|
|
|
101
|
-
if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
|
|
121
|
+
if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH') && !options.skipParentForMissing) {
|
|
102
122
|
// Check write permission on parent container
|
|
103
123
|
const parentPath = getParentPath(storagePath);
|
|
104
124
|
checkPath = parentPath;
|
|
@@ -107,6 +127,10 @@ export async function authorize(request, reply, options = {}) {
|
|
|
107
127
|
checkUrl = buildResourceUrl(request, parentUrlPath);
|
|
108
128
|
checkIsContainer = true;
|
|
109
129
|
}
|
|
130
|
+
// skipParentForMissing: callers with virtual endpoints (e.g. /proxy in
|
|
131
|
+
// #378) want WAC checked against the URL path itself even when no
|
|
132
|
+
// backing storage exists. Without this opt-out, POST /proxy on a
|
|
133
|
+
// single-user pod gets authorized against /, which is too permissive.
|
|
110
134
|
|
|
111
135
|
// Check WAC permissions
|
|
112
136
|
const { allowed, wacAllow, paymentRequired, paid, balance, currency } = await checkAccess({
|
package/src/config.js
CHANGED
|
@@ -46,6 +46,13 @@ export const defaults = {
|
|
|
46
46
|
// Git HTTP backend
|
|
47
47
|
git: false,
|
|
48
48
|
|
|
49
|
+
// CORS proxy (#378) — pod-hosted, WAC-gated proxy for browser apps to
|
|
50
|
+
// fetch arbitrary upstreams that don't return CORS headers.
|
|
51
|
+
corsProxy: false,
|
|
52
|
+
corsProxyMaxBytes: 50 * 1024 * 1024, // 50 MB ceiling on upstream response size
|
|
53
|
+
corsProxyTimeoutMs: 30_000, // 30 s deadline for upstream to send headers (504 if exceeded). The timeout does not apply during body streaming — body size is capped by corsProxyMaxBytes; see follow-up for streaming-phase timeout.
|
|
54
|
+
corsProxyMaxRedirects: 5, // each redirect re-validated for SSRF
|
|
55
|
+
|
|
49
56
|
// Nostr relay
|
|
50
57
|
nostr: false,
|
|
51
58
|
nostrPath: '/relay',
|
|
@@ -147,6 +154,10 @@ const envMap = {
|
|
|
147
154
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
148
155
|
JSS_MASHLIB_MODULE: 'mashlibModule',
|
|
149
156
|
JSS_GIT: 'git',
|
|
157
|
+
JSS_CORS_PROXY: 'corsProxy',
|
|
158
|
+
JSS_CORS_PROXY_MAX_BYTES: 'corsProxyMaxBytes',
|
|
159
|
+
JSS_CORS_PROXY_TIMEOUT_MS: 'corsProxyTimeoutMs',
|
|
160
|
+
JSS_CORS_PROXY_MAX_REDIRECTS: 'corsProxyMaxRedirects',
|
|
150
161
|
JSS_NOSTR: 'nostr',
|
|
151
162
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
152
163
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
@@ -208,6 +219,7 @@ const BOOLEAN_KEYS = new Set([
|
|
|
208
219
|
'mashlib',
|
|
209
220
|
'mashlibCdn',
|
|
210
221
|
'git',
|
|
222
|
+
'corsProxy',
|
|
211
223
|
'nostr',
|
|
212
224
|
'webrtc',
|
|
213
225
|
'terminal',
|
|
@@ -243,7 +255,13 @@ function parseEnvValue(value, key) {
|
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
// Numeric values for known numeric keys
|
|
246
|
-
if ((key === 'port' ||
|
|
258
|
+
if ((key === 'port' ||
|
|
259
|
+
key === 'nostrMaxEvents' ||
|
|
260
|
+
key === 'payCost' ||
|
|
261
|
+
key === 'payRate' ||
|
|
262
|
+
key === 'corsProxyMaxBytes' ||
|
|
263
|
+
key === 'corsProxyTimeoutMs' ||
|
|
264
|
+
key === 'corsProxyMaxRedirects') && !isNaN(value)) {
|
|
247
265
|
return parseInt(value, 10);
|
|
248
266
|
}
|
|
249
267
|
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS proxy handler — fetches an arbitrary upstream URL on behalf of a
|
|
3
|
+
* browser-side caller and returns the response with CORS headers, so
|
|
4
|
+
* pod-hosted apps can talk to non-CORS-friendly origins (#378).
|
|
5
|
+
*
|
|
6
|
+
* URL shape: `/proxy?url=<absolute-url>` — query-param keeps the path a
|
|
7
|
+
* plain Solid resource for WAC purposes (acl can sit at /proxy or /.acl
|
|
8
|
+
* and inherit normally).
|
|
9
|
+
*
|
|
10
|
+
* Auth: WAC. The standard /proxy resource is checked by server.js's
|
|
11
|
+
* authorize() pipeline before this handler runs; pod owner controls
|
|
12
|
+
* access by writing an .acl on /proxy.
|
|
13
|
+
*
|
|
14
|
+
* SSRF: validateExternalUrl() runs on the user-supplied URL AND on every
|
|
15
|
+
* redirect target. Manual redirect following is non-negotiable — letting
|
|
16
|
+
* fetch() follow redirects automatically would let an attacker host a
|
|
17
|
+
* 302 to 169.254.169.254 and bypass the initial guard.
|
|
18
|
+
*
|
|
19
|
+
* Body limits: streamed, with a configurable max-bytes ceiling enforced
|
|
20
|
+
* during streaming. Timeout via AbortController.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
24
|
+
import { Readable, Transform } from 'stream';
|
|
25
|
+
|
|
26
|
+
// CORS headers applied to every proxy response. Same shape/source-of-truth
|
|
27
|
+
// pattern as GIT_CORS_HEADERS in src/handlers/git.js (#374).
|
|
28
|
+
//
|
|
29
|
+
// Allow-Headers must list every request header the proxy actually
|
|
30
|
+
// forwards (see FORWARD_REQUEST_HEADERS) — otherwise browsers reject
|
|
31
|
+
// preflight before the request reaches us. Plus:
|
|
32
|
+
// - Authorization, DPoP — for WAC + Solid-OIDC auth to *this* pod
|
|
33
|
+
// - X-Upstream-Authorization — opt-in upstream credential (renamed to
|
|
34
|
+
// Authorization on the way out, see pickRequestHeaders)
|
|
35
|
+
//
|
|
36
|
+
// Expose-Headers includes WAC-Allow so browser clients can render auth
|
|
37
|
+
// UX based on the pod's policy, plus the usual content/etag/location set.
|
|
38
|
+
//
|
|
39
|
+
// Allow-Credentials is set explicitly to 'false' to override the
|
|
40
|
+
// server-wide global CORS hook (which uses 'true' for the rest of the
|
|
41
|
+
// pod). Combining 'true' with `Allow-Origin: *` is a CORS spec
|
|
42
|
+
// violation that browsers reject — and an anonymous-readable proxy
|
|
43
|
+
// doesn't have a use case for credentialed cross-origin anyway (Cookie
|
|
44
|
+
// is stripped before forwarding).
|
|
45
|
+
export const PROXY_CORS_HEADERS = {
|
|
46
|
+
'Access-Control-Allow-Origin': '*',
|
|
47
|
+
'Access-Control-Allow-Credentials': 'false',
|
|
48
|
+
'Access-Control-Allow-Methods': 'GET, POST, HEAD, OPTIONS',
|
|
49
|
+
'Access-Control-Allow-Headers': [
|
|
50
|
+
'Content-Type',
|
|
51
|
+
'Authorization',
|
|
52
|
+
'DPoP',
|
|
53
|
+
'X-Upstream-Authorization',
|
|
54
|
+
'Git-Protocol',
|
|
55
|
+
'Accept',
|
|
56
|
+
'Accept-Encoding',
|
|
57
|
+
'Accept-Language',
|
|
58
|
+
'If-Match',
|
|
59
|
+
'If-None-Match',
|
|
60
|
+
'If-Modified-Since',
|
|
61
|
+
'Range',
|
|
62
|
+
'User-Agent',
|
|
63
|
+
].join(', '),
|
|
64
|
+
'Access-Control-Expose-Headers': 'Content-Type, ETag, Last-Modified, Link, Location, WWW-Authenticate, WAC-Allow, X-Cost, X-Balance, X-Pay-Currency',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function setProxyCorsHeaders(reply) {
|
|
68
|
+
for (const [k, v] of Object.entries(PROXY_CORS_HEADERS)) {
|
|
69
|
+
reply.header(k, v);
|
|
70
|
+
}
|
|
71
|
+
// Hardening against the proxy being used as an XSS vector: if a user
|
|
72
|
+
// navigates a browser to /proxy?url=https://evil/page.html, the
|
|
73
|
+
// upstream HTML would otherwise execute in this pod's origin and
|
|
74
|
+
// could exfiltrate other pod resources. CSP `sandbox` neutralizes
|
|
75
|
+
// scripts/plugins/navigation when rendered as a document; nosniff
|
|
76
|
+
// prevents MIME-sniffing tricks. fetch()-based callers are
|
|
77
|
+
// unaffected (they consume the raw bytes regardless of these
|
|
78
|
+
// response-side defenses).
|
|
79
|
+
reply.header('Content-Security-Policy', 'sandbox');
|
|
80
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Headers we forward from the caller to the upstream. Anything not on
|
|
84
|
+
// this list is dropped — origin/cookie/host/referer would either confuse
|
|
85
|
+
// upstream auth or leak the proxying pod's identity.
|
|
86
|
+
//
|
|
87
|
+
// Authorization is deliberately NOT forwarded by default: the browser
|
|
88
|
+
// uses it to authenticate to *this* pod (WAC, Solid-OIDC bearer/DPoP),
|
|
89
|
+
// and silently leaking that token to an arbitrary upstream is a security
|
|
90
|
+
// hole. Callers who genuinely need to send credentials to the upstream
|
|
91
|
+
// (e.g. a GitHub PAT for a private repo) opt in via the
|
|
92
|
+
// X-Upstream-Authorization header — pickRequestHeaders renames that to
|
|
93
|
+
// Authorization on the way out.
|
|
94
|
+
//
|
|
95
|
+
// DPoP is similarly not forwarded — it's bound to this pod's URL and
|
|
96
|
+
// would be rejected by any upstream anyway.
|
|
97
|
+
// Note: Content-Length is *not* forwarded. JSS registers a wildcard
|
|
98
|
+
// parseAs:'buffer' content parser (server.js), so request bodies arrive
|
|
99
|
+
// as a Buffer that we pass through to fetch unchanged — but we keep a
|
|
100
|
+
// defensive JSON.stringify branch downstream in case anything ever
|
|
101
|
+
// changes that and a parsed object lands here. Either way, the
|
|
102
|
+
// caller-declared length isn't reliable for the upstream call. Node's
|
|
103
|
+
// fetch sets Content-Length automatically based on the actual body.
|
|
104
|
+
// (Browsers send Content-Length on simple requests without needing it
|
|
105
|
+
// in Access-Control-Allow-Headers, since it's CORS-safelisted.)
|
|
106
|
+
const FORWARD_REQUEST_HEADERS = new Set([
|
|
107
|
+
'accept',
|
|
108
|
+
'accept-encoding',
|
|
109
|
+
'accept-language',
|
|
110
|
+
'content-type',
|
|
111
|
+
'git-protocol',
|
|
112
|
+
'if-match',
|
|
113
|
+
'if-none-match',
|
|
114
|
+
'if-modified-since',
|
|
115
|
+
'range',
|
|
116
|
+
'user-agent',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const UPSTREAM_AUTH_HEADER = 'x-upstream-authorization';
|
|
120
|
+
|
|
121
|
+
// Headers we strip from the upstream response before returning to the
|
|
122
|
+
// caller. Encoding-related ones are removed because Node's fetch already
|
|
123
|
+
// transparently decodes; passing through Content-Encoding would
|
|
124
|
+
// double-decompress in the browser.
|
|
125
|
+
//
|
|
126
|
+
// Content-Length is stripped because (a) we strip Content-Encoding so
|
|
127
|
+
// the body length may differ from the upstream-declared length after
|
|
128
|
+
// decompression, and (b) we may truncate mid-stream when maxBytes is
|
|
129
|
+
// exceeded. Forwarding the upstream Content-Length in either case
|
|
130
|
+
// causes ERR_CONTENT_LENGTH_MISMATCH or hangs in the client. Node sends
|
|
131
|
+
// the actual length (or chunked) automatically.
|
|
132
|
+
//
|
|
133
|
+
// WAC-Allow / X-Cost / X-Balance / X-Pay-Currency are stripped because
|
|
134
|
+
// they describe *this* pod's ACL decision and ledger debit; an upstream
|
|
135
|
+
// must not be able to spoof them. server.js sets these headers *before*
|
|
136
|
+
// calling handleCorsProxy (in the proxy preHandler, after authorize()),
|
|
137
|
+
// and stripping them here ensures copyResponseHeaders can never
|
|
138
|
+
// overwrite the local values when iterating upstream headers.
|
|
139
|
+
const STRIP_RESPONSE_HEADERS = new Set([
|
|
140
|
+
'content-encoding',
|
|
141
|
+
'content-length',
|
|
142
|
+
'transfer-encoding',
|
|
143
|
+
'connection',
|
|
144
|
+
'keep-alive',
|
|
145
|
+
// Strip set-cookie — we never want to forward upstream cookies into
|
|
146
|
+
// our origin's domain (would let upstream set cookies on the pod).
|
|
147
|
+
'set-cookie',
|
|
148
|
+
// Pod-authoritative headers: never accept upstream values for these.
|
|
149
|
+
'wac-allow',
|
|
150
|
+
'x-cost',
|
|
151
|
+
'x-balance',
|
|
152
|
+
'x-pay-currency',
|
|
153
|
+
// Range/206 mismatch: we may truncate the body at maxBytes, in which
|
|
154
|
+
// case the upstream's Content-Range no longer matches the bytes the
|
|
155
|
+
// client gets. Stripping both Content-Range and Accept-Ranges means
|
|
156
|
+
// callers don't trust a stale range — they receive whatever bytes
|
|
157
|
+
// actually streamed and can re-request with a smaller window if
|
|
158
|
+
// needed. Phase 2 could revisit if someone needs byte-accurate range
|
|
159
|
+
// proxying.
|
|
160
|
+
'content-range',
|
|
161
|
+
'accept-ranges',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
165
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
166
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
167
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'HEAD', 'OPTIONS']);
|
|
168
|
+
|
|
169
|
+
function pickRequestHeaders(reqHeaders) {
|
|
170
|
+
const out = {};
|
|
171
|
+
for (const [k, v] of Object.entries(reqHeaders)) {
|
|
172
|
+
if (v == null) continue;
|
|
173
|
+
const lower = k.toLowerCase();
|
|
174
|
+
if (FORWARD_REQUEST_HEADERS.has(lower)) {
|
|
175
|
+
out[k] = v;
|
|
176
|
+
} else if (lower === UPSTREAM_AUTH_HEADER) {
|
|
177
|
+
// Opt-in upstream credential: client supplies the token under
|
|
178
|
+
// X-Upstream-Authorization, we relay it as Authorization upstream.
|
|
179
|
+
// Pod's own Authorization (used to auth to /proxy) never leaves
|
|
180
|
+
// the pod — see FORWARD_REQUEST_HEADERS.
|
|
181
|
+
out['Authorization'] = v;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function copyResponseHeaders(reply, fetchResponse) {
|
|
188
|
+
for (const [k, v] of fetchResponse.headers) {
|
|
189
|
+
if (STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) continue;
|
|
190
|
+
reply.header(k, v);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stream the upstream body to the caller, enforcing a byte cap mid-stream.
|
|
196
|
+
* Uses Node 18+ Readable.fromWeb to bridge the fetch ReadableStream into
|
|
197
|
+
* a Node Readable, then a Transform passthrough counts bytes and ends
|
|
198
|
+
* the stream cleanly if maxBytes is exceeded.
|
|
199
|
+
*
|
|
200
|
+
* Note on the timeout: once status/headers are sent we can't change the
|
|
201
|
+
* status code (Fastify throws ERR_HTTP_HEADERS_SENT), so the deadline
|
|
202
|
+
* applies to the headers-received phase only. A slow-streaming upstream
|
|
203
|
+
* past the deadline is *not* killed mid-stream in Phase 1 — see the
|
|
204
|
+
* follow-up tracked in #378 / the open issue list. Mid-stream errors
|
|
205
|
+
* (byte cap, fetch error) terminate the response with a truncated body
|
|
206
|
+
* rather than a thrown error.
|
|
207
|
+
*/
|
|
208
|
+
function streamUpstream(reply, fetchResponse, maxBytes, abortController) {
|
|
209
|
+
if (!fetchResponse.body) {
|
|
210
|
+
return reply.send();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Byte cap with two boundary cases handled:
|
|
214
|
+
// - chunk smaller than remaining: pass through, keep counting
|
|
215
|
+
// - chunk meets or exceeds remaining: emit just enough to fill the
|
|
216
|
+
// cap and end the stream right then. Previously a chunk that hit
|
|
217
|
+
// the cap *exactly* (chunk.length === remaining) was passed
|
|
218
|
+
// through without ending — subsequent chunks fell into the
|
|
219
|
+
// "remaining <= 0" branch and were dropped silently while the
|
|
220
|
+
// stream stayed open, which can hang slow-streaming clients.
|
|
221
|
+
let bytesSeen = 0;
|
|
222
|
+
const counter = new Transform({
|
|
223
|
+
transform(chunk, _encoding, callback) {
|
|
224
|
+
const remaining = maxBytes - bytesSeen;
|
|
225
|
+
if (remaining <= 0) {
|
|
226
|
+
// Defensive: shouldn't happen because we end on first overflow,
|
|
227
|
+
// but if a chunk arrives after we've started shutting down,
|
|
228
|
+
// drop it.
|
|
229
|
+
return callback();
|
|
230
|
+
}
|
|
231
|
+
if (chunk.length < remaining) {
|
|
232
|
+
bytesSeen += chunk.length;
|
|
233
|
+
return callback(null, chunk);
|
|
234
|
+
}
|
|
235
|
+
// chunk fills or exceeds the cap — emit (up to) `remaining` bytes
|
|
236
|
+
// and end the stream right now.
|
|
237
|
+
const slice = chunk.length === remaining ? chunk : chunk.slice(0, remaining);
|
|
238
|
+
bytesSeen += slice.length;
|
|
239
|
+
this.push(slice);
|
|
240
|
+
this.push(null);
|
|
241
|
+
abortController.abort();
|
|
242
|
+
return callback();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
Readable.fromWeb(fetchResponse.body)
|
|
247
|
+
.on('error', () => counter.end())
|
|
248
|
+
.pipe(counter);
|
|
249
|
+
|
|
250
|
+
return reply.send(counter);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Handle a CORS proxy request.
|
|
255
|
+
*
|
|
256
|
+
* @param {FastifyRequest} request
|
|
257
|
+
* @param {FastifyReply} reply
|
|
258
|
+
* @param {object} options
|
|
259
|
+
* @param {number} [options.maxBytes]
|
|
260
|
+
* @param {number} [options.timeoutMs]
|
|
261
|
+
* @param {number} [options.maxRedirects]
|
|
262
|
+
*/
|
|
263
|
+
export async function handleCorsProxy(request, reply, options = {}) {
|
|
264
|
+
setProxyCorsHeaders(reply);
|
|
265
|
+
|
|
266
|
+
// CORS preflight — short-circuit without fetching.
|
|
267
|
+
if (request.method === 'OPTIONS') {
|
|
268
|
+
return reply.code(204).send();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!ALLOWED_METHODS.has(request.method)) {
|
|
272
|
+
// HTTP requires 405 to carry an Allow header listing supported methods.
|
|
273
|
+
reply.header('Allow', [...ALLOWED_METHODS].join(', '));
|
|
274
|
+
return reply.code(405).send({ error: 'Method not allowed', method: request.method });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const targetUrl = request.query?.url;
|
|
278
|
+
if (!targetUrl || typeof targetUrl !== 'string') {
|
|
279
|
+
return reply.code(400).send({ error: 'Missing required query parameter: url' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
283
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
284
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
285
|
+
|
|
286
|
+
// Validate the initial URL, then follow redirects manually re-validating
|
|
287
|
+
// each hop's Location header. Required because fetch's automatic redirect
|
|
288
|
+
// following bypasses our SSRF guard at the second hop.
|
|
289
|
+
let currentUrl = targetUrl;
|
|
290
|
+
let currentMethod = request.method;
|
|
291
|
+
let redirectsLeft = maxRedirects;
|
|
292
|
+
|
|
293
|
+
// Body for non-GET/HEAD methods. Read once up front; we may need it on
|
|
294
|
+
// the first hop and (for 307/308) on redirected hops.
|
|
295
|
+
let body = null;
|
|
296
|
+
if (currentMethod !== 'GET' && currentMethod !== 'HEAD') {
|
|
297
|
+
body = request.rawBody ?? request.body;
|
|
298
|
+
if (typeof body === 'object' && !(body instanceof Buffer) && body !== null) {
|
|
299
|
+
body = JSON.stringify(body);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Forwarded headers are computed once and reused across redirect hops,
|
|
304
|
+
// EXCEPT Authorization. If the client opted in via X-Upstream-Authorization,
|
|
305
|
+
// its credential is now in forwardHeaders.Authorization. We must strip
|
|
306
|
+
// it on cross-origin redirects: a redirect to evil.com would otherwise
|
|
307
|
+
// leak the user's upstream token (e.g. a GitHub PAT) to whoever the
|
|
308
|
+
// attacker controls. See cross-origin redirect handling below.
|
|
309
|
+
const forwardHeaders = pickRequestHeaders(request.headers);
|
|
310
|
+
const initialOrigin = (() => {
|
|
311
|
+
try { return new URL(currentUrl).origin; } catch { return null; }
|
|
312
|
+
})();
|
|
313
|
+
|
|
314
|
+
while (true) {
|
|
315
|
+
const validation = await validateExternalUrl(currentUrl, {
|
|
316
|
+
requireHttps: false, // allow http:// — pod operator can lock down via .acl scope
|
|
317
|
+
blockPrivateIPs: true,
|
|
318
|
+
resolveDNS: true,
|
|
319
|
+
});
|
|
320
|
+
if (!validation.valid) {
|
|
321
|
+
return reply.code(400).send({ error: 'Invalid upstream URL', detail: validation.error });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const abortController = new AbortController();
|
|
325
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
326
|
+
|
|
327
|
+
let upstream;
|
|
328
|
+
try {
|
|
329
|
+
upstream = await fetch(currentUrl, {
|
|
330
|
+
method: currentMethod,
|
|
331
|
+
headers: forwardHeaders,
|
|
332
|
+
body: (currentMethod === 'GET' || currentMethod === 'HEAD') ? undefined : body,
|
|
333
|
+
redirect: 'manual',
|
|
334
|
+
signal: abortController.signal,
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
if (err.name === 'AbortError') {
|
|
339
|
+
return reply.code(504).send({ error: 'Upstream timeout' });
|
|
340
|
+
}
|
|
341
|
+
return reply.code(502).send({ error: 'Upstream fetch failed', detail: err.message });
|
|
342
|
+
}
|
|
343
|
+
// Headers received — clear the deadline. Streaming-phase timeouts
|
|
344
|
+
// are a known Phase 1 limitation: once Fastify has sent headers we
|
|
345
|
+
// can't change the status code, and the destroy-source-during-pipe
|
|
346
|
+
// pattern doesn't reliably terminate the response. Tracked as a
|
|
347
|
+
// follow-up.
|
|
348
|
+
clearTimeout(timeoutId);
|
|
349
|
+
|
|
350
|
+
// Manual redirect handling: 301/302/303 → GET (per HTTP semantics),
|
|
351
|
+
// 307/308 → preserve method and body.
|
|
352
|
+
if ([301, 302, 303, 307, 308].includes(upstream.status)) {
|
|
353
|
+
const location = upstream.headers.get('location');
|
|
354
|
+
if (!location) {
|
|
355
|
+
return reply.code(502).send({ error: 'Upstream redirect with no Location header' });
|
|
356
|
+
}
|
|
357
|
+
if (--redirectsLeft < 0) {
|
|
358
|
+
return reply.code(502).send({ error: `Exceeded ${maxRedirects} redirects` });
|
|
359
|
+
}
|
|
360
|
+
// Resolve relative redirects against the previous URL. Malformed
|
|
361
|
+
// Location values throw — bubble that up as a 502 instead of 500.
|
|
362
|
+
try {
|
|
363
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
364
|
+
} catch {
|
|
365
|
+
return reply.code(502).send({ error: 'Upstream redirect with malformed Location', location });
|
|
366
|
+
}
|
|
367
|
+
if ([301, 302, 303].includes(upstream.status)) {
|
|
368
|
+
// Per fetch / RFC 7231 redirect semantics:
|
|
369
|
+
// - 303: any non-GET/HEAD becomes GET with no body
|
|
370
|
+
// - 301/302: POST → GET with no body (legacy quirk; spec
|
|
371
|
+
// technically says preserve method, but every real client
|
|
372
|
+
// downgrades POST to GET); HEAD stays HEAD; GET stays GET
|
|
373
|
+
// HEAD must never be turned into GET — it'd cause an
|
|
374
|
+
// unexpected body to stream on what was a body-less request.
|
|
375
|
+
if (currentMethod !== 'GET' && currentMethod !== 'HEAD') {
|
|
376
|
+
currentMethod = 'GET';
|
|
377
|
+
body = null;
|
|
378
|
+
delete forwardHeaders['content-length'];
|
|
379
|
+
delete forwardHeaders['Content-Length'];
|
|
380
|
+
delete forwardHeaders['content-type'];
|
|
381
|
+
delete forwardHeaders['Content-Type'];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Cross-origin redirect: strip Authorization to prevent leaking
|
|
385
|
+
// the X-Upstream-Authorization-derived credential to a different
|
|
386
|
+
// origin. Curl, browsers, and well-behaved HTTP clients all do
|
|
387
|
+
// this. The opt-in upstream auth is bound to the origin the
|
|
388
|
+
// client requested.
|
|
389
|
+
try {
|
|
390
|
+
const nextOrigin = new URL(currentUrl).origin;
|
|
391
|
+
if (initialOrigin && nextOrigin !== initialOrigin) {
|
|
392
|
+
delete forwardHeaders['authorization'];
|
|
393
|
+
delete forwardHeaders['Authorization'];
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// currentUrl was just validated above, so this should never
|
|
397
|
+
// throw; if it somehow does, drop Authorization to fail safe.
|
|
398
|
+
delete forwardHeaders['authorization'];
|
|
399
|
+
delete forwardHeaders['Authorization'];
|
|
400
|
+
}
|
|
401
|
+
// Drain the redirect body to free the connection; we never return it.
|
|
402
|
+
try { await upstream.body?.cancel(); } catch { /* ignore */ }
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Final response — stream it back.
|
|
407
|
+
reply.code(upstream.status);
|
|
408
|
+
copyResponseHeaders(reply, upstream);
|
|
409
|
+
setProxyCorsHeaders(reply); // reapply in case copyResponseHeaders set conflicting CORS values
|
|
410
|
+
|
|
411
|
+
return streamUpstream(reply, upstream, maxBytes, abortController);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Match a request URL *path* (not full URL) against the proxy route.
|
|
417
|
+
* Used by server.js to decide whether the proxy preHandler should fire
|
|
418
|
+
* and whether the standard WAC hook should skip. Callers must pass the
|
|
419
|
+
* already-split path component (e.g. `request.url.split('?')[0]`).
|
|
420
|
+
*/
|
|
421
|
+
export function isCorsProxyRequest(urlPath) {
|
|
422
|
+
return urlPath === '/proxy';
|
|
423
|
+
}
|
package/src/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { notificationsPlugin } from './notifications/index.js';
|
|
|
12
12
|
import { startFileWatcher } from './notifications/events.js';
|
|
13
13
|
import { idpPlugin } from './idp/index.js';
|
|
14
14
|
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
15
|
+
import { handleCorsProxy, isCorsProxyRequest, setProxyCorsHeaders } from './handlers/cors-proxy.js';
|
|
15
16
|
import { AccessMode } from './wac/parser.js';
|
|
16
17
|
import { registerNostrRelay } from './nostr/relay.js';
|
|
17
18
|
import { createPayHandler, isPayRequest } from './handlers/pay.js';
|
|
@@ -71,6 +72,18 @@ export function createServer(options = {}) {
|
|
|
71
72
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
72
73
|
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
73
74
|
const gitEnabled = options.git ?? false;
|
|
75
|
+
// CORS proxy (#378) — OFF by default. Numeric settings get the
|
|
76
|
+
// sane-default fallback if the env var or config file supplies a
|
|
77
|
+
// non-finite/non-positive value (e.g. JSS_CORS_PROXY_MAX_BYTES=banana
|
|
78
|
+
// would otherwise leave the cap as the string "banana", making
|
|
79
|
+
// `bytesSeen > "banana"` always false and silently disabling the
|
|
80
|
+
// safety limit).
|
|
81
|
+
const positiveInt = (v, fallback) =>
|
|
82
|
+
(typeof v === 'number' && Number.isFinite(v) && v > 0) ? v : fallback;
|
|
83
|
+
const corsProxyEnabled = options.corsProxy === true;
|
|
84
|
+
const corsProxyMaxBytes = positiveInt(options.corsProxyMaxBytes, 50 * 1024 * 1024);
|
|
85
|
+
const corsProxyTimeoutMs = positiveInt(options.corsProxyTimeoutMs, 30_000);
|
|
86
|
+
const corsProxyMaxRedirects = positiveInt(options.corsProxyMaxRedirects, 5);
|
|
74
87
|
// Nostr relay is OFF by default
|
|
75
88
|
const nostrEnabled = options.nostr ?? false;
|
|
76
89
|
const nostrPath = options.nostrPath ?? '/relay';
|
|
@@ -386,7 +399,13 @@ export function createServer(options = {}) {
|
|
|
386
399
|
return;
|
|
387
400
|
}
|
|
388
401
|
|
|
389
|
-
|
|
402
|
+
// Only inspect the path component — splitting the full URL on '/'
|
|
403
|
+
// would catch dot-prefixed segments inside query-string values
|
|
404
|
+
// (e.g. /proxy?url=https://example.com/.git/config), rejecting
|
|
405
|
+
// legitimate proxy requests for upstream URLs that happen to
|
|
406
|
+
// contain dotfile-like path segments. The dotfile guard is about
|
|
407
|
+
// *this* pod's filesystem, not what the URL looks like.
|
|
408
|
+
const segments = request.url.split('?')[0].split('/');
|
|
390
409
|
const hasForbiddenDotfile = segments.some(seg =>
|
|
391
410
|
seg.startsWith('.') &&
|
|
392
411
|
seg.length > 1 &&
|
|
@@ -439,6 +458,92 @@ export function createServer(options = {}) {
|
|
|
439
458
|
fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains }));
|
|
440
459
|
}
|
|
441
460
|
|
|
461
|
+
// CORS proxy (#378) — WAC-gated. Standard authorize() path runs against
|
|
462
|
+
// /proxy as a virtual resource; pod owner controls access by writing an
|
|
463
|
+
// .acl on /proxy (or inheriting from /.acl). OPTIONS preflight returns
|
|
464
|
+
// 204 directly without auth so browser CORS checks succeed before sign-in.
|
|
465
|
+
if (corsProxyEnabled) {
|
|
466
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
467
|
+
const urlPath = request.url.split('?')[0];
|
|
468
|
+
if (!isCorsProxyRequest(urlPath)) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// OPTIONS preflight short-circuits to the handler (which returns
|
|
473
|
+
// 204 + proxy CORS headers) without going through authorize() at
|
|
474
|
+
// all. authorize() does have its own OPTIONS short-circuit, but
|
|
475
|
+
// routing through here keeps the preflight off the auth/payment
|
|
476
|
+
// path entirely — preflights must never debit ledgers or evaluate
|
|
477
|
+
// PaymentConditions.
|
|
478
|
+
if (request.method === 'OPTIONS') {
|
|
479
|
+
return handleCorsProxy(request, reply, {
|
|
480
|
+
maxBytes: corsProxyMaxBytes,
|
|
481
|
+
timeoutMs: corsProxyTimeoutMs,
|
|
482
|
+
maxRedirects: corsProxyMaxRedirects,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Don't override requiredMode — let authorize() derive it from the
|
|
487
|
+
// request method via getRequiredMode(). GET/HEAD need READ on the
|
|
488
|
+
// /proxy resource, POST needs APPEND/WRITE — pod owners can grant
|
|
489
|
+
// these separately via ACL modes (e.g. acl:Read for browse-only,
|
|
490
|
+
// acl:Append/Write for proxying side-effecting POSTs upstream).
|
|
491
|
+
//
|
|
492
|
+
// skipParentForMissing prevents authorize()'s "non-existent resource +
|
|
493
|
+
// write method → check parent container" fallback from kicking in.
|
|
494
|
+
// /proxy is a virtual endpoint with no backing storage, so the
|
|
495
|
+
// fallback would route POST authorization to / (the root) instead
|
|
496
|
+
// of /proxy — too permissive. With this flag, authorize() checks
|
|
497
|
+
// ACLs against /proxy directly regardless of storage existence.
|
|
498
|
+
const { authorized, webId, wacAllow, authError, paymentRequired, paid, balance, currency } =
|
|
499
|
+
await authorize(request, reply, { skipParentForMissing: true });
|
|
500
|
+
request.webId = webId;
|
|
501
|
+
request.wacAllow = wacAllow;
|
|
502
|
+
|
|
503
|
+
// Surface paid-access bookkeeping the same way the standard WAC
|
|
504
|
+
// hook does (lines 564-569 below). When a /proxy ACL uses a
|
|
505
|
+
// PaymentCondition and the caller has sufficient balance,
|
|
506
|
+
// checkAccess() returns paid (the cost), balance, and currency —
|
|
507
|
+
// browser-side renders charge UI off these. Without this, ledger
|
|
508
|
+
// debit happens silently.
|
|
509
|
+
if (paid !== undefined) {
|
|
510
|
+
reply.header('X-Cost', String(paid));
|
|
511
|
+
reply.header('X-Balance', String(balance));
|
|
512
|
+
if (currency) reply.header('X-Pay-Currency', currency);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Set WAC-Allow on success too, matching the global WAC hook
|
|
516
|
+
// (line 562 area). Browser clients read it via Expose-Headers
|
|
517
|
+
// to render auth UX. Without this, only 401/403/402 responses
|
|
518
|
+
// carry WAC-Allow, which is inconsistent.
|
|
519
|
+
reply.header('WAC-Allow', wacAllow);
|
|
520
|
+
|
|
521
|
+
// ACL with a PaymentCondition surfaces as 402 here — mirrors the
|
|
522
|
+
// git handler at src/server.js:418 and the standard WAC hook so
|
|
523
|
+
// payment-gated /proxy ACLs behave consistently.
|
|
524
|
+
if (paymentRequired) {
|
|
525
|
+
setProxyCorsHeaders(reply);
|
|
526
|
+
reply.header('WAC-Allow', wacAllow);
|
|
527
|
+
return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (request.method !== 'OPTIONS' && !authorized) {
|
|
531
|
+
// Apply proxy CORS headers BEFORE handleUnauthorized so the 401/403
|
|
532
|
+
// is readable by browser clients (without these the browser surfaces
|
|
533
|
+
// the response as a generic CORS failure — same shape as #374).
|
|
534
|
+
setProxyCorsHeaders(reply);
|
|
535
|
+
reply.header('WAC-Allow', wacAllow);
|
|
536
|
+
return handleUnauthorized(request, reply, webId !== null, wacAllow, authError);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return handleCorsProxy(request, reply, {
|
|
540
|
+
maxBytes: corsProxyMaxBytes,
|
|
541
|
+
timeoutMs: corsProxyTimeoutMs,
|
|
542
|
+
maxRedirects: corsProxyMaxRedirects,
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
442
547
|
// Authorization hook - check WAC permissions
|
|
443
548
|
// Skip for pod creation endpoint (needs special handling)
|
|
444
549
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
@@ -460,6 +565,7 @@ export function createServer(options = {}) {
|
|
|
460
565
|
request.url.startsWith('/.well-known/') ||
|
|
461
566
|
(nostrEnabled && request.url.startsWith(nostrPath)) ||
|
|
462
567
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
568
|
+
(corsProxyEnabled && isCorsProxyRequest(request.url.split('?')[0])) ||
|
|
463
569
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
464
570
|
isProfileAP ||
|
|
465
571
|
request.url.startsWith('/storage/') ||
|