@nifrajs/proxy 2.12.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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/index.ts +235 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nifra Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @nifrajs/proxy
|
|
2
|
+
|
|
3
|
+
Reverse proxy for nifra - stream requests to one fixed upstream origin with hop-by-hop hygiene, SSRF-proof by construction. Dependency-free.
|
|
4
|
+
|
|
5
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @nifrajs/proxy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { server } from "@nifrajs/core"
|
|
17
|
+
import { createProxy } from "@nifrajs/proxy"
|
|
18
|
+
|
|
19
|
+
const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
|
|
20
|
+
const app = server().mountFetch("/api", upstream, { stripPrefix: true })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The upstream is a **bare origin** fixed at construction - the forwarded URL is built by mutating a clone of that origin, never by resolving request-derived strings, so no request input can change which host is dialed. Hop-by-hop and `Connection`-nominated headers are stripped in both directions, forwarding metadata (`X-Forwarded-*`) is dropped unless `forwardClientIp: true`, upstream redirects are never followed, and TLS verification has no off switch. Unreachable upstreams answer a flat `502`; the deadline (`timeoutMs`, default 30s) answers `504`.
|
|
24
|
+
|
|
25
|
+
## Docs
|
|
26
|
+
|
|
27
|
+
- Reference: <https://nifra.dev/docs>
|
|
28
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
29
|
+
|
|
30
|
+
MIT
|
|
31
|
+
|
|
32
|
+
## For AI agents
|
|
33
|
+
|
|
34
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
35
|
+
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
36
|
+
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
37
|
+
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
|
38
|
+
done-gate, or `nifra mcp` to give the agent live project tools.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse proxy to ONE fixed upstream origin, secure by construction.
|
|
3
|
+
*
|
|
4
|
+
* The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
|
|
5
|
+
* clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
|
|
6
|
+
* request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
|
|
7
|
+
* through this proxy is unrepresentable, not filtered.
|
|
8
|
+
*
|
|
9
|
+
* Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
|
|
10
|
+
* by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
|
|
11
|
+
* the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
|
|
12
|
+
* Hono proxy CVE-2026-71849 class). Forwarding metadata (`Forwarded`, `X-Forwarded-*`) is stripped
|
|
13
|
+
* unless `forwardClientIp: true`, in which case the caller chain is appended truthfully. Upstream
|
|
14
|
+
* redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
|
|
15
|
+
* the proxy anywhere. TLS verification is always on; there is no knob to disable it.
|
|
16
|
+
*/
|
|
17
|
+
/** Structural slice of a nifra `Context` the proxy reads - a plain `Request` works too. */
|
|
18
|
+
export interface ProxyContext {
|
|
19
|
+
readonly req: Request;
|
|
20
|
+
readonly clientIp?: string | undefined;
|
|
21
|
+
readonly signal?: AbortSignal | undefined;
|
|
22
|
+
}
|
|
23
|
+
export interface ProxyOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The upstream to forward every request to, as a **bare origin** (`https://api.internal:8443`).
|
|
26
|
+
* A path, query, fragment, or credentials in the URL throws at construction; only `http:` and
|
|
27
|
+
* `https:` are accepted. The forwarded URL is this origin + the incoming path and query.
|
|
28
|
+
*/
|
|
29
|
+
readonly upstream: string;
|
|
30
|
+
/** Mount prefix to remove from the incoming path before forwarding (`/api` + request
|
|
31
|
+
* `/api/users` → upstream `/users`). A path outside the prefix is forwarded unchanged. */
|
|
32
|
+
readonly stripPrefix?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Forward caller metadata upstream. Default **false**: `Forwarded` and `X-Forwarded-*` headers
|
|
35
|
+
* are stripped, so a client-forged chain never reaches the upstream. When true, the inbound
|
|
36
|
+
* `X-Forwarded-For` is kept with the observed caller IP appended (pass a `ProxyContext` so
|
|
37
|
+
* `c.clientIp` - already filtered by the app's trust declaration - is what gets appended). With a
|
|
38
|
+
* bare `Request`, forwarding metadata is suppressed rather than passed through.
|
|
39
|
+
*/
|
|
40
|
+
readonly forwardClientIp?: boolean;
|
|
41
|
+
/** Upstream response deadline in milliseconds. Default `30_000`; expiry answers `504`. */
|
|
42
|
+
readonly timeoutMs?: number;
|
|
43
|
+
/** Static headers to set on every forwarded request (after hygiene, so they always win). */
|
|
44
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
45
|
+
}
|
|
46
|
+
/** Forward a request (or a nifra context) to the configured upstream. */
|
|
47
|
+
export type ProxyHandler = (input: Request | ProxyContext) => Promise<Response>;
|
|
48
|
+
/**
|
|
49
|
+
* Create a proxy handler bound to one upstream origin.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
|
|
53
|
+
* app.mountFetch("/api", upstream, { stripPrefix: true })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Streams both directions, preserves method/path/query/status, strips hop-by-hop and
|
|
57
|
+
* Connection-nominated headers both ways, never follows upstream redirects, and answers flat
|
|
58
|
+
* errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
|
|
59
|
+
* WebSocket upgrade is not proxied.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createProxy(options: ProxyOptions): ProxyHandler;
|
|
62
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,2FAA2F;AAC3F,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;CAC1C;AAED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;8FAC0F;IAC1F,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAA;IAClC,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACpD;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AA0F/E;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAkF/D"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse proxy to ONE fixed upstream origin, secure by construction.
|
|
3
|
+
*
|
|
4
|
+
* The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
|
|
5
|
+
* clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
|
|
6
|
+
* request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
|
|
7
|
+
* through this proxy is unrepresentable, not filtered.
|
|
8
|
+
*
|
|
9
|
+
* Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
|
|
10
|
+
* by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
|
|
11
|
+
* the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
|
|
12
|
+
* Hono proxy CVE-2026-71849 class). Forwarding metadata (`Forwarded`, `X-Forwarded-*`) is stripped
|
|
13
|
+
* unless `forwardClientIp: true`, in which case the caller chain is appended truthfully. Upstream
|
|
14
|
+
* redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
|
|
15
|
+
* the proxy anywhere. TLS verification is always on; there is no knob to disable it.
|
|
16
|
+
*/
|
|
17
|
+
/** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
|
|
18
|
+
const HOP_BY_HOP = new Set([
|
|
19
|
+
"connection",
|
|
20
|
+
"keep-alive",
|
|
21
|
+
"proxy-authenticate",
|
|
22
|
+
"proxy-authorization",
|
|
23
|
+
"proxy-connection",
|
|
24
|
+
"te",
|
|
25
|
+
"trailer",
|
|
26
|
+
"transfer-encoding",
|
|
27
|
+
"upgrade",
|
|
28
|
+
]);
|
|
29
|
+
/** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
|
|
30
|
+
const FORWARDING = new Set([
|
|
31
|
+
"forwarded",
|
|
32
|
+
"x-forwarded-for",
|
|
33
|
+
"x-forwarded-host",
|
|
34
|
+
"x-forwarded-proto",
|
|
35
|
+
]);
|
|
36
|
+
const EMPTY = new Set();
|
|
37
|
+
/** Header names nominated hop-by-hop by a `Connection` header (lowercased). */
|
|
38
|
+
function connectionNominated(headers) {
|
|
39
|
+
const raw = headers.get("connection");
|
|
40
|
+
if (raw === null)
|
|
41
|
+
return EMPTY;
|
|
42
|
+
const out = new Set();
|
|
43
|
+
for (const token of raw.split(",")) {
|
|
44
|
+
const name = token.trim().toLowerCase();
|
|
45
|
+
if (name !== "")
|
|
46
|
+
out.add(name);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function dropHeader(name, nominated) {
|
|
51
|
+
return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-");
|
|
52
|
+
}
|
|
53
|
+
const flatError = (status, error) => new Response(JSON.stringify({ ok: false, error }), {
|
|
54
|
+
status,
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
});
|
|
57
|
+
function upstreamRequestHeaders(req, clientIp, options) {
|
|
58
|
+
const nominated = connectionNominated(req.headers);
|
|
59
|
+
const out = new Headers();
|
|
60
|
+
for (const [name, value] of req.headers) {
|
|
61
|
+
// `host` names the proxy, not the upstream - fetch derives the right one from the target URL.
|
|
62
|
+
if (name === "host" || dropHeader(name, nominated))
|
|
63
|
+
continue;
|
|
64
|
+
if (FORWARDING.has(name))
|
|
65
|
+
continue;
|
|
66
|
+
out.append(name, value);
|
|
67
|
+
}
|
|
68
|
+
if (options.forwardClientIp === true && clientIp !== undefined) {
|
|
69
|
+
const prior = req.headers.get("x-forwarded-for");
|
|
70
|
+
const chain = prior !== null ? `${prior}, ${clientIp}` : clientIp;
|
|
71
|
+
if (chain !== undefined)
|
|
72
|
+
out.set("x-forwarded-for", chain);
|
|
73
|
+
out.set("x-forwarded-proto", new URL(req.url).protocol.slice(0, -1));
|
|
74
|
+
const host = req.headers.get("host");
|
|
75
|
+
if (host !== null)
|
|
76
|
+
out.set("x-forwarded-host", host);
|
|
77
|
+
}
|
|
78
|
+
if (options.headers !== undefined) {
|
|
79
|
+
for (const [name, value] of Object.entries(options.headers))
|
|
80
|
+
out.set(name, value);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function relayedResponseHeaders(upstream) {
|
|
85
|
+
const nominated = connectionNominated(upstream.headers);
|
|
86
|
+
const out = new Headers();
|
|
87
|
+
for (const [name, value] of upstream.headers) {
|
|
88
|
+
if (dropHeader(name, nominated))
|
|
89
|
+
continue;
|
|
90
|
+
// fetch() already decoded the body per Content-Encoding, so the stored encoding and length no
|
|
91
|
+
// longer describe the bytes being relayed. (Re-compression is the compression() middleware's job.)
|
|
92
|
+
if (name === "content-encoding" || name === "content-length")
|
|
93
|
+
continue;
|
|
94
|
+
// Re-added below via getSetCookie() so multiple cookies survive on every runtime.
|
|
95
|
+
if (name === "set-cookie")
|
|
96
|
+
continue;
|
|
97
|
+
out.append(name, value);
|
|
98
|
+
}
|
|
99
|
+
for (const cookie of upstream.headers.getSetCookie())
|
|
100
|
+
out.append("set-cookie", cookie);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Create a proxy handler bound to one upstream origin.
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
|
|
108
|
+
* app.mountFetch("/api", upstream, { stripPrefix: true })
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* Streams both directions, preserves method/path/query/status, strips hop-by-hop and
|
|
112
|
+
* Connection-nominated headers both ways, never follows upstream redirects, and answers flat
|
|
113
|
+
* errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
|
|
114
|
+
* WebSocket upgrade is not proxied.
|
|
115
|
+
*/
|
|
116
|
+
export function createProxy(options) {
|
|
117
|
+
const base = new URL(options.upstream);
|
|
118
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
119
|
+
throw new Error(`[nifra/proxy] upstream must be http(s), got ${base.protocol}`);
|
|
120
|
+
}
|
|
121
|
+
if (base.pathname !== "/" ||
|
|
122
|
+
base.search !== "" ||
|
|
123
|
+
base.hash !== "" ||
|
|
124
|
+
base.username !== "" ||
|
|
125
|
+
base.password !== "") {
|
|
126
|
+
throw new Error("[nifra/proxy] upstream must be a bare origin (no path, query, fragment, or credentials)");
|
|
127
|
+
}
|
|
128
|
+
const stripPrefix = options.stripPrefix;
|
|
129
|
+
if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
|
|
130
|
+
throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one');
|
|
131
|
+
}
|
|
132
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
133
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
134
|
+
throw new Error("[nifra/proxy] timeoutMs must be a positive number");
|
|
135
|
+
}
|
|
136
|
+
for (const name of Object.keys(options.headers ?? {})) {
|
|
137
|
+
const normalized = name.toLowerCase();
|
|
138
|
+
if (normalized === "host" ||
|
|
139
|
+
HOP_BY_HOP.has(normalized) ||
|
|
140
|
+
normalized.startsWith("proxy-") ||
|
|
141
|
+
FORWARDING.has(normalized)) {
|
|
142
|
+
throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return async (input) => {
|
|
146
|
+
const req = input instanceof Request ? input : input.req;
|
|
147
|
+
const clientIp = input instanceof Request ? undefined : input.clientIp;
|
|
148
|
+
const callerSignal = input instanceof Request ? input.signal : (input.signal ?? input.req.signal);
|
|
149
|
+
const incoming = new URL(req.url);
|
|
150
|
+
let path = incoming.pathname;
|
|
151
|
+
if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
|
|
152
|
+
const rest = path.slice(stripPrefix.length);
|
|
153
|
+
path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
|
|
154
|
+
}
|
|
155
|
+
// Mutating a clone of the configured origin - never URL-resolving a request-derived string -
|
|
156
|
+
// means no path (e.g. `//evil.example/x`) can change the host being dialed.
|
|
157
|
+
const target = new URL(base);
|
|
158
|
+
target.pathname = path;
|
|
159
|
+
target.search = incoming.search;
|
|
160
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
161
|
+
const signal = callerSignal !== undefined ? AbortSignal.any([timeout, callerSignal]) : timeout;
|
|
162
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : req.body;
|
|
163
|
+
const init = {
|
|
164
|
+
method: req.method,
|
|
165
|
+
headers: upstreamRequestHeaders(req, clientIp, options),
|
|
166
|
+
redirect: "manual",
|
|
167
|
+
signal,
|
|
168
|
+
};
|
|
169
|
+
if (body !== null && body !== undefined) {
|
|
170
|
+
init.body = body;
|
|
171
|
+
init.duplex = "half";
|
|
172
|
+
}
|
|
173
|
+
let upstream;
|
|
174
|
+
try {
|
|
175
|
+
upstream = await fetch(target, init);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (timeout.aborted)
|
|
179
|
+
return flatError(504, "gateway_timeout");
|
|
180
|
+
if (callerSignal?.aborted === true)
|
|
181
|
+
throw error;
|
|
182
|
+
return flatError(502, "bad_gateway");
|
|
183
|
+
}
|
|
184
|
+
return new Response(upstream.body, {
|
|
185
|
+
status: upstream.status,
|
|
186
|
+
statusText: upstream.statusText,
|
|
187
|
+
headers: relayedResponseHeaders(upstream),
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/proxy",
|
|
3
|
+
"version": "2.12.0",
|
|
4
|
+
"description": "Reverse proxy for nifra - stream requests to one fixed upstream origin with hop-by-hop hygiene, SSRF-proof by construction. Dependency-free.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"proxy",
|
|
10
|
+
"reverse-proxy",
|
|
11
|
+
"http-proxy",
|
|
12
|
+
"streaming",
|
|
13
|
+
"upstream"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"bun": "./src/index.ts",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@nifrajs/core": "workspace:*"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
44
|
+
"directory": "packages/proxy"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse proxy to ONE fixed upstream origin, secure by construction.
|
|
3
|
+
*
|
|
4
|
+
* The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
|
|
5
|
+
* clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
|
|
6
|
+
* request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
|
|
7
|
+
* through this proxy is unrepresentable, not filtered.
|
|
8
|
+
*
|
|
9
|
+
* Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
|
|
10
|
+
* by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
|
|
11
|
+
* the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
|
|
12
|
+
* Hono proxy CVE-2026-71849 class). Forwarding metadata (`Forwarded`, `X-Forwarded-*`) is stripped
|
|
13
|
+
* unless `forwardClientIp: true`, in which case the caller chain is appended truthfully. Upstream
|
|
14
|
+
* redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
|
|
15
|
+
* the proxy anywhere. TLS verification is always on; there is no knob to disable it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Structural slice of a nifra `Context` the proxy reads - a plain `Request` works too. */
|
|
19
|
+
export interface ProxyContext {
|
|
20
|
+
readonly req: Request
|
|
21
|
+
readonly clientIp?: string | undefined
|
|
22
|
+
readonly signal?: AbortSignal | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProxyOptions {
|
|
26
|
+
/**
|
|
27
|
+
* The upstream to forward every request to, as a **bare origin** (`https://api.internal:8443`).
|
|
28
|
+
* A path, query, fragment, or credentials in the URL throws at construction; only `http:` and
|
|
29
|
+
* `https:` are accepted. The forwarded URL is this origin + the incoming path and query.
|
|
30
|
+
*/
|
|
31
|
+
readonly upstream: string
|
|
32
|
+
/** Mount prefix to remove from the incoming path before forwarding (`/api` + request
|
|
33
|
+
* `/api/users` → upstream `/users`). A path outside the prefix is forwarded unchanged. */
|
|
34
|
+
readonly stripPrefix?: string
|
|
35
|
+
/**
|
|
36
|
+
* Forward caller metadata upstream. Default **false**: `Forwarded` and `X-Forwarded-*` headers
|
|
37
|
+
* are stripped, so a client-forged chain never reaches the upstream. When true, the inbound
|
|
38
|
+
* `X-Forwarded-For` is kept with the observed caller IP appended (pass a `ProxyContext` so
|
|
39
|
+
* `c.clientIp` - already filtered by the app's trust declaration - is what gets appended). With a
|
|
40
|
+
* bare `Request`, forwarding metadata is suppressed rather than passed through.
|
|
41
|
+
*/
|
|
42
|
+
readonly forwardClientIp?: boolean
|
|
43
|
+
/** Upstream response deadline in milliseconds. Default `30_000`; expiry answers `504`. */
|
|
44
|
+
readonly timeoutMs?: number
|
|
45
|
+
/** Static headers to set on every forwarded request (after hygiene, so they always win). */
|
|
46
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Forward a request (or a nifra context) to the configured upstream. */
|
|
50
|
+
export type ProxyHandler = (input: Request | ProxyContext) => Promise<Response>
|
|
51
|
+
|
|
52
|
+
/** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
|
|
53
|
+
const HOP_BY_HOP: ReadonlySet<string> = new Set([
|
|
54
|
+
"connection",
|
|
55
|
+
"keep-alive",
|
|
56
|
+
"proxy-authenticate",
|
|
57
|
+
"proxy-authorization",
|
|
58
|
+
"proxy-connection",
|
|
59
|
+
"te",
|
|
60
|
+
"trailer",
|
|
61
|
+
"transfer-encoding",
|
|
62
|
+
"upgrade",
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
|
|
66
|
+
const FORWARDING: ReadonlySet<string> = new Set([
|
|
67
|
+
"forwarded",
|
|
68
|
+
"x-forwarded-for",
|
|
69
|
+
"x-forwarded-host",
|
|
70
|
+
"x-forwarded-proto",
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
const EMPTY: ReadonlySet<string> = new Set()
|
|
74
|
+
|
|
75
|
+
/** Header names nominated hop-by-hop by a `Connection` header (lowercased). */
|
|
76
|
+
function connectionNominated(headers: Headers): ReadonlySet<string> {
|
|
77
|
+
const raw = headers.get("connection")
|
|
78
|
+
if (raw === null) return EMPTY
|
|
79
|
+
const out = new Set<string>()
|
|
80
|
+
for (const token of raw.split(",")) {
|
|
81
|
+
const name = token.trim().toLowerCase()
|
|
82
|
+
if (name !== "") out.add(name)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function dropHeader(name: string, nominated: ReadonlySet<string>): boolean {
|
|
88
|
+
return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const flatError = (status: number, error: string): Response =>
|
|
92
|
+
new Response(JSON.stringify({ ok: false, error }), {
|
|
93
|
+
status,
|
|
94
|
+
headers: { "content-type": "application/json" },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
function upstreamRequestHeaders(
|
|
98
|
+
req: Request,
|
|
99
|
+
clientIp: string | undefined,
|
|
100
|
+
options: ProxyOptions,
|
|
101
|
+
): Headers {
|
|
102
|
+
const nominated = connectionNominated(req.headers)
|
|
103
|
+
const out = new Headers()
|
|
104
|
+
for (const [name, value] of req.headers) {
|
|
105
|
+
// `host` names the proxy, not the upstream - fetch derives the right one from the target URL.
|
|
106
|
+
if (name === "host" || dropHeader(name, nominated)) continue
|
|
107
|
+
if (FORWARDING.has(name)) continue
|
|
108
|
+
out.append(name, value)
|
|
109
|
+
}
|
|
110
|
+
if (options.forwardClientIp === true && clientIp !== undefined) {
|
|
111
|
+
const prior = req.headers.get("x-forwarded-for")
|
|
112
|
+
const chain = prior !== null ? `${prior}, ${clientIp}` : clientIp
|
|
113
|
+
if (chain !== undefined) out.set("x-forwarded-for", chain)
|
|
114
|
+
out.set("x-forwarded-proto", new URL(req.url).protocol.slice(0, -1))
|
|
115
|
+
const host = req.headers.get("host")
|
|
116
|
+
if (host !== null) out.set("x-forwarded-host", host)
|
|
117
|
+
}
|
|
118
|
+
if (options.headers !== undefined) {
|
|
119
|
+
for (const [name, value] of Object.entries(options.headers)) out.set(name, value)
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function relayedResponseHeaders(upstream: Response): Headers {
|
|
125
|
+
const nominated = connectionNominated(upstream.headers)
|
|
126
|
+
const out = new Headers()
|
|
127
|
+
for (const [name, value] of upstream.headers) {
|
|
128
|
+
if (dropHeader(name, nominated)) continue
|
|
129
|
+
// fetch() already decoded the body per Content-Encoding, so the stored encoding and length no
|
|
130
|
+
// longer describe the bytes being relayed. (Re-compression is the compression() middleware's job.)
|
|
131
|
+
if (name === "content-encoding" || name === "content-length") continue
|
|
132
|
+
// Re-added below via getSetCookie() so multiple cookies survive on every runtime.
|
|
133
|
+
if (name === "set-cookie") continue
|
|
134
|
+
out.append(name, value)
|
|
135
|
+
}
|
|
136
|
+
for (const cookie of upstream.headers.getSetCookie()) out.append("set-cookie", cookie)
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a proxy handler bound to one upstream origin.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
|
|
145
|
+
* app.mountFetch("/api", upstream, { stripPrefix: true })
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* Streams both directions, preserves method/path/query/status, strips hop-by-hop and
|
|
149
|
+
* Connection-nominated headers both ways, never follows upstream redirects, and answers flat
|
|
150
|
+
* errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
|
|
151
|
+
* WebSocket upgrade is not proxied.
|
|
152
|
+
*/
|
|
153
|
+
export function createProxy(options: ProxyOptions): ProxyHandler {
|
|
154
|
+
const base = new URL(options.upstream)
|
|
155
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
156
|
+
throw new Error(`[nifra/proxy] upstream must be http(s), got ${base.protocol}`)
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
base.pathname !== "/" ||
|
|
160
|
+
base.search !== "" ||
|
|
161
|
+
base.hash !== "" ||
|
|
162
|
+
base.username !== "" ||
|
|
163
|
+
base.password !== ""
|
|
164
|
+
) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"[nifra/proxy] upstream must be a bare origin (no path, query, fragment, or credentials)",
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
const stripPrefix = options.stripPrefix
|
|
170
|
+
if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
|
|
171
|
+
throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one')
|
|
172
|
+
}
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? 30_000
|
|
174
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
175
|
+
throw new Error("[nifra/proxy] timeoutMs must be a positive number")
|
|
176
|
+
}
|
|
177
|
+
for (const name of Object.keys(options.headers ?? {})) {
|
|
178
|
+
const normalized = name.toLowerCase()
|
|
179
|
+
if (
|
|
180
|
+
normalized === "host" ||
|
|
181
|
+
HOP_BY_HOP.has(normalized) ||
|
|
182
|
+
normalized.startsWith("proxy-") ||
|
|
183
|
+
FORWARDING.has(normalized)
|
|
184
|
+
) {
|
|
185
|
+
throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return async (input) => {
|
|
190
|
+
const req = input instanceof Request ? input : input.req
|
|
191
|
+
const clientIp = input instanceof Request ? undefined : input.clientIp
|
|
192
|
+
const callerSignal =
|
|
193
|
+
input instanceof Request ? input.signal : (input.signal ?? input.req.signal)
|
|
194
|
+
|
|
195
|
+
const incoming = new URL(req.url)
|
|
196
|
+
let path = incoming.pathname
|
|
197
|
+
if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
|
|
198
|
+
const rest = path.slice(stripPrefix.length)
|
|
199
|
+
path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest
|
|
200
|
+
}
|
|
201
|
+
// Mutating a clone of the configured origin - never URL-resolving a request-derived string -
|
|
202
|
+
// means no path (e.g. `//evil.example/x`) can change the host being dialed.
|
|
203
|
+
const target = new URL(base)
|
|
204
|
+
target.pathname = path
|
|
205
|
+
target.search = incoming.search
|
|
206
|
+
|
|
207
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
208
|
+
const signal = callerSignal !== undefined ? AbortSignal.any([timeout, callerSignal]) : timeout
|
|
209
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : req.body
|
|
210
|
+
const init: RequestInit & { duplex?: "half" } = {
|
|
211
|
+
method: req.method,
|
|
212
|
+
headers: upstreamRequestHeaders(req, clientIp, options),
|
|
213
|
+
redirect: "manual",
|
|
214
|
+
signal,
|
|
215
|
+
}
|
|
216
|
+
if (body !== null && body !== undefined) {
|
|
217
|
+
init.body = body
|
|
218
|
+
init.duplex = "half"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let upstream: Response
|
|
222
|
+
try {
|
|
223
|
+
upstream = await fetch(target, init)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (timeout.aborted) return flatError(504, "gateway_timeout")
|
|
226
|
+
if (callerSignal?.aborted === true) throw error
|
|
227
|
+
return flatError(502, "bad_gateway")
|
|
228
|
+
}
|
|
229
|
+
return new Response(upstream.body, {
|
|
230
|
+
status: upstream.status,
|
|
231
|
+
statusText: upstream.statusText,
|
|
232
|
+
headers: relayedResponseHeaders(upstream),
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|