@shivaedev/platform 0.10.0 → 0.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/CHANGELOG.md +13 -0
- package/README.md +20 -1
- package/dist/server/boundedBody.d.ts +14 -0
- package/dist/server/boundedBody.d.ts.map +1 -0
- package/dist/server/boundedBody.js +36 -0
- package/dist/server/boundedBody.js.map +1 -0
- package/dist/server/boundedRpcRequest.d.ts.map +1 -1
- package/dist/server/boundedRpcRequest.js +7 -26
- package/dist/server/boundedRpcRequest.js.map +1 -1
- package/dist/server/rpc-server/local.d.ts +2 -0
- package/dist/server/rpc-server/local.d.ts.map +1 -1
- package/dist/server/rpc-server/local.js +12 -5
- package/dist/server/rpc-server/local.js.map +1 -1
- package/dist/server/rpc-server/trustedProxy.d.ts +1 -0
- package/dist/server/rpc-server/trustedProxy.d.ts.map +1 -1
- package/dist/server/rpc-server/trustedProxy.js +41 -1
- package/dist/server/rpc-server/trustedProxy.js.map +1 -1
- package/package.json +9 -3
- package/src/server/boundedBody.ts +45 -0
- package/src/server/boundedRpcRequest.ts +10 -29
- package/src/server/rpc-server/local.ts +12 -6
- package/src/server/rpc-server/trustedProxy.ts +41 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `trustedProxyPeer(trustedProxy?)` from `@shivaedev/platform/rpc-server/local.ts` compiles the host's IP address and CIDR proxy matching into a reusable address predicate. Missing or malformed configuration matches no peer.
|
|
8
|
+
- `trustedProxyRanges(trustedProxy?)` from the same module returns equivalent ranges for forwarded-header integrations. The host matcher uses the same normalization: mapped IPv6 addresses and CIDRs become IPv4 entries, including hexadecimal forms, while broader IPv6 ranges retain both address families. Missing or malformed configuration returns an empty list.
|
|
9
|
+
|
|
10
|
+
## 0.11.0 - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Export `readBoundedBody` and `RequestBodyTooLarge` from `@shivaedev/platform/boundedBody.ts`. Native Node HTTP routes can apply Effect's `HttpIncomingMessage.MaxBodySize` and return 413 for oversized content lengths or chunked bodies while keeping the connection usable and preserving accepted bytes.
|
|
15
|
+
|
|
3
16
|
## 0.10.0 - 2026-10-16
|
|
4
17
|
|
|
5
18
|
### Added
|
package/README.md
CHANGED
|
@@ -67,6 +67,21 @@ The listener uses `HOST` or `127.0.0.1`, uses `PORT` or 3000, and reads `DATABAS
|
|
|
67
67
|
|
|
68
68
|
RPC request bodies are limited to 1 MiB by default. Set `MAX_BODY_SIZE` to a byte count or override it with `config.maxBodySize`, a nonnegative safe integer. An oversized NDJSON body returns a 4xx before any RPC handler runs; real HTTP tests cover fixed-length and chunked requests, byte counting, and configuration precedence. This limit applies to the host's RPC route.
|
|
69
69
|
|
|
70
|
+
For a native Node HTTP route, use `readBoundedBody` from `@shivaedev/platform/boundedBody.ts` with Effect's `HttpIncomingMessage.MaxBodySize`. It returns an `ArrayBuffer` without decoding the bytes. Oversized `content-length` headers are rejected before reading; chunked overflow drains the remaining body and preserves the connection for another request. `RequestBodyTooLarge` is a typed failure that implements Effect's `HttpServerRespondable` protocol with status 413; other read errors remain native `HttpServerError` failures. Consume the body once through this reader and decode the result in the application.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { ByteSize, Effect } from "effect";
|
|
74
|
+
import { HttpIncomingMessage, HttpRouter, HttpServerResponse } from "effect/http";
|
|
75
|
+
import { readBoundedBody } from "@shivaedev/platform/boundedBody.ts";
|
|
76
|
+
|
|
77
|
+
const routes = HttpRouter.add("POST", "/webhook", (request) =>
|
|
78
|
+
readBoundedBody(request).pipe(
|
|
79
|
+
Effect.map((body) => HttpServerResponse.uint8Array(new Uint8Array(body))),
|
|
80
|
+
Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(64 * 1024)),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
70
85
|
RPC uses the existing `localRpcGuard`: it requires a loopback peer and Host, same-origin or absent Origin, and the NDJSON content type. Session lookup uses transport headers. With a session policy, contracts declare `Authenticated`, `MaybeAuthenticated` and `RequestTracing` as needed; the host supplies their implementations. For `AuthenticatedUser`, merge `authenticatedUserLayer(sessions)` beside the handlers as shown below. Declared errors retain their Schema types. Applications still own resource authorization and use `rpcBoundary` when they need to map unexpected failures.
|
|
71
86
|
|
|
72
87
|
For local tools, use `policy: "loopback"` with contracts that do not use session middleware to serve RPC without a session provider. The guard requires a loopback peer and Host, absent or same-origin Origin, and NDJSON content type. This policy does not use `TRUSTED_PROXY` or `config.trustedProxy`. Set `rpcPath` to choose another native RPC route; `/rpc` remains the default.
|
|
@@ -200,6 +215,10 @@ By default, the guard returns HTTP 403 before running the effect unless the tran
|
|
|
200
215
|
|
|
201
216
|
The same module exports `loopbackOnly(handler)` for local page routes and `sameOrigin(request)` for an HTTP Origin check. `loopbackOnly` checks Host and any known peer address; it permits an absent peer address so embedded Fetch page handlers remain usable. It does not check Origin or content type.
|
|
202
217
|
|
|
218
|
+
`trustedProxyPeer(trustedProxy?)` compiles the guard's proxy address list into an `(address: string) => boolean` predicate. Use it with the transport's remote address when another route needs the same proxy trust decision. It supports the same IP addresses, CIDR ranges and IPv4-mapped IPv6 peers as `appHost`; absent or malformed configuration matches no peer. It does not read forwarded headers.
|
|
219
|
+
|
|
220
|
+
`trustedProxyRanges(trustedProxy?)` returns the equivalent IP addresses and CIDR ranges used by that matcher. Mapped IPv6 addresses and CIDRs become IPv4 entries: `::ffff:127.0.0.0/104` becomes `127.0.0.0/8`, including hexadecimal mapped forms. Broader IPv6 ranges retain both native IPv6 and mapped IPv4 coverage. Missing or malformed configuration returns an empty list. Pass these ranges to a forwarded-header integration after checking the transport with `trustedProxyPeer`.
|
|
221
|
+
|
|
203
222
|
Declare expected refusals in the RPC's error schema:
|
|
204
223
|
|
|
205
224
|
```ts
|
|
@@ -404,7 +423,7 @@ Import the module that defines the name; there is no package-root entry. Paths b
|
|
|
404
423
|
| `rpc-server/session.ts` | `authenticatedLayer(policy)`, `maybeAuthenticatedLayer(policy)`, `resolveSession(policy, headers, rpc)`; `SessionShape`, `SessionProvider<Session>`, `SessionPolicy<Session>`. `authenticatedUserLayer(policy)` requires `UserSessionShape` and implements `AuthenticatedUser`. |
|
|
405
424
|
| `rpc-server/adapters/better-auth-sessions.ts` | `betterAuthSessions(getSession)`; `GetSession<Session>` is a Promise lookup returning `Session \| null`. |
|
|
406
425
|
| `rpc-server/origin.ts` | `trustedOrigins({ allow, missing })`, `originRequest(headers, rpc)`; `OriginPolicy`, `OriginRequest`, `TrustedOriginsOptions`. |
|
|
407
|
-
| `rpc-server/local.ts` | `localRpcGuard(effect)`: HTTP RPC guard using native request and serialization services; `loopbackOnly(handler)`, `sameOrigin(request)`. |
|
|
426
|
+
| `rpc-server/local.ts` | `localRpcGuard(effect)`: HTTP RPC guard using native request and serialization services; `loopbackOnly(handler)`, `sameOrigin(request)`, `trustedProxyPeer(trustedProxy?)`, `trustedProxyRanges(trustedProxy?)`. |
|
|
408
427
|
| `rpc-server/transport.ts` | `transportHeaders(rpcHeaders)`: Effect returning the headers used for a transport-level decision. |
|
|
409
428
|
| `rpc-server/tracing.ts` | `requestTracingLayer({ header?, sensitive? })`; `RequestTracingOptions`. |
|
|
410
429
|
| `rpc-server/redact.ts` | `redact(value, sensitive?)`: unknown diagnostic value. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerError, type HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
3
|
+
declare const RequestBodyTooLarge_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
4
|
+
readonly _tag: "RequestBodyTooLarge";
|
|
5
|
+
} & Readonly<A>;
|
|
6
|
+
export declare class RequestBodyTooLarge extends RequestBodyTooLarge_base<{
|
|
7
|
+
readonly request: HttpServerRequest.HttpServerRequest;
|
|
8
|
+
readonly limit: bigint;
|
|
9
|
+
}> implements HttpServerRespondable.Respondable {
|
|
10
|
+
[HttpServerRespondable.symbol](): Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>;
|
|
11
|
+
}
|
|
12
|
+
export declare const readBoundedBody: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<ArrayBuffer, HttpServerError.HttpServerError | RequestBodyTooLarge, never>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=boundedBody.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundedBody.d.ts","sourceRoot":"","sources":["../../src/server/boundedBody.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,MAAM,EAAU,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAuB,eAAe,EAAE,KAAK,iBAAiB,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;;;;AAEtI,qBAAa,mBACZ,SAAQ,yBAAwC;IAC/C,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB,CACD,YAAW,qBAAqB,CAAC,WAAW;IAE5C,CAAC,qBAAqB,CAAC,MAAM,CAAC,uEAE7B;CACD;AAED,eAAO,MAAM,eAAe,4IA4B1B,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
|
|
2
|
+
import { Data, Effect, Stream } from "effect";
|
|
3
|
+
import { HttpIncomingMessage, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
4
|
+
export class RequestBodyTooLarge extends Data.TaggedError("RequestBodyTooLarge") {
|
|
5
|
+
[HttpServerRespondable.symbol]() {
|
|
6
|
+
return Effect.succeed(HttpServerResponse.empty({ status: 413 }));
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export const readBoundedBody = Effect.fnUntraced(function* (request) {
|
|
10
|
+
const incoming = NodeHttpServerRequest.toIncomingMessage(request);
|
|
11
|
+
const limit = yield* HttpIncomingMessage.MaxBodySize;
|
|
12
|
+
const read = Effect.gen(function* () {
|
|
13
|
+
const length = request.headers["content-length"];
|
|
14
|
+
if (limit !== undefined && length !== undefined && BigInt(length) > limit) {
|
|
15
|
+
return yield* new RequestBodyTooLarge({ limit, request });
|
|
16
|
+
}
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let size = 0n;
|
|
19
|
+
yield* NodeStream.fromReadable({
|
|
20
|
+
closeOnDone: false,
|
|
21
|
+
evaluate: () => incoming,
|
|
22
|
+
onError: (cause) => new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) }),
|
|
23
|
+
}).pipe(Stream.runForEach((chunk) => {
|
|
24
|
+
size += BigInt(chunk.byteLength);
|
|
25
|
+
if (limit !== undefined && size > limit) {
|
|
26
|
+
return Effect.fail(new RequestBodyTooLarge({ limit, request }));
|
|
27
|
+
}
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
return Effect.void;
|
|
30
|
+
}));
|
|
31
|
+
return Uint8Array.from(Buffer.concat(chunks)).buffer;
|
|
32
|
+
});
|
|
33
|
+
// Drain unread bytes without destroying the socket so the server can send a response.
|
|
34
|
+
return yield* read.pipe(Effect.onError(() => Effect.sync(() => incoming.resume())));
|
|
35
|
+
});
|
|
36
|
+
//# sourceMappingURL=boundedBody.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boundedBody.js","sourceRoot":"","sources":["../../src/server/boundedBody.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAA0B,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtI,MAAM,OAAO,mBACZ,SAAQ,IAAI,CAAC,WAAW,CAAC,qBAAqB,CAG5C;IAGF,CAAC,qBAAqB,CAAC,MAAM,CAAC;QAC7B,OAAO,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;IAClE,CAAC;CACD;AAED,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,OAA4C;IACvG,MAAM,QAAQ,GAAG,qBAAqB,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAClE,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,mBAAmB,CAAC,WAAW,CAAC;IACrD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAChC,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;QACjD,IAAI,KAAK,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;YAC3E,OAAO,KAAK,CAAC,CAAC,IAAI,mBAAmB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;QAC3D,CAAC;QACD,MAAM,MAAM,GAAiB,EAAE,CAAC;QAChC,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,KAAK,CAAC,CAAC,UAAU,CAAC,YAAY,CAA8C;YAC3E,WAAW,EAAE,KAAK;YAClB,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ;YACxB,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,eAAe,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,iBAAiB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;SAC9H,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,EAAE;YAC3B,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YACjC,IAAI,KAAK,KAAK,SAAS,IAAI,IAAI,GAAG,KAAK,EAAE,CAAC;gBACzC,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,mBAAmB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;YACjE,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACnB,OAAO,MAAM,CAAC,IAAI,CAAC;QACpB,CAAC,CAAC,CACF,CAAC;QACF,OAAO,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IACtD,CAAC,CAAC,CAAC;IACH,sFAAsF;IACtF,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;AACrF,CAAC,CAAC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boundedRpcRequest.d.ts","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"boundedRpcRequest.d.ts","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGtE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,GAAG,iBAAiB,CAAC,iBAAiB,CAuBnH"}
|
|
@@ -1,30 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerError } from "effect/http";
|
|
3
|
+
import { readBoundedBody } from "./boundedBody.js";
|
|
4
4
|
export function boundedRpcRequest(request) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const text = Effect.gen(function* () {
|
|
10
|
-
const limit = yield* HttpIncomingMessage.MaxBodySize;
|
|
11
|
-
const chunks = [];
|
|
12
|
-
let size = 0n;
|
|
13
|
-
yield* NodeStream.fromReadable({
|
|
14
|
-
closeOnDone: false,
|
|
15
|
-
evaluate: () => incoming,
|
|
16
|
-
onError: error,
|
|
17
|
-
}).pipe(Stream.runForEach((chunk) => {
|
|
18
|
-
size += BigInt(chunk.byteLength);
|
|
19
|
-
if (limit !== undefined && size > limit) {
|
|
20
|
-
return Effect.fail(error(new Error("RPC body exceeds MAX_BODY_SIZE")));
|
|
21
|
-
}
|
|
22
|
-
chunks.push(chunk);
|
|
23
|
-
return Effect.void;
|
|
24
|
-
}));
|
|
25
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
26
|
-
}).pipe(Effect.onError(() => Effect.sync(() => incoming.resume())), Effect.cached, Effect.runSync);
|
|
27
|
-
const arrayBuffer = Effect.map(text, (body) => new TextEncoder().encode(body).buffer);
|
|
5
|
+
const arrayBuffer = readBoundedBody(request).pipe(Effect.mapError((cause) => cause._tag === "RequestBodyTooLarge"
|
|
6
|
+
? new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) })
|
|
7
|
+
: cause), Effect.cached, Effect.runSync);
|
|
8
|
+
const text = Effect.map(arrayBuffer, (body) => new TextDecoder().decode(body));
|
|
28
9
|
// The native text reader destroys the HTTP socket on overflow, preventing a 4xx response.
|
|
29
10
|
return new Proxy(request, {
|
|
30
11
|
get: (target, key, receiver) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boundedRpcRequest.js","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"boundedRpcRequest.js","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,eAAe,EAA0B,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,UAAU,iBAAiB,CAAC,OAA4C;IAC7E,MAAM,WAAW,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC,IAAI,CAChD,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE,CACzB,KAAK,CAAC,IAAI,KAAK,qBAAqB;QACnC,CAAC,CAAC,IAAI,eAAe,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,iBAAiB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;QAC5G,CAAC,CAAC,KAAK,CACR,EACD,MAAM,CAAC,MAAM,EACb,MAAM,CAAC,OAAO,CACd,CAAC;IACF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IAC/E,0FAA0F;IAC1F,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACzB,GAAG,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE;YAC9B,IAAI,GAAG,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,IAAI,CAAC;YACb,CAAC;YACD,IAAI,GAAG,KAAK,aAAa,EAAE,CAAC;gBAC3B,OAAO,WAAW,CAAC;YACpB,CAAC;YACD,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC3C,CAAC;KACD,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -3,5 +3,7 @@ import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
|
3
3
|
import { RpcSerialization } from "effect/rpc";
|
|
4
4
|
export declare function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean;
|
|
5
5
|
export declare function loopbackOnly<E, R>(handler: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>): (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>;
|
|
6
|
+
export declare function trustedProxyPeer(trustedProxy?: string): (address: string) => boolean;
|
|
7
|
+
export declare function trustedProxyRanges(trustedProxy?: string): string[];
|
|
6
8
|
export declare function localRpcGuard<E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, trustedProxy?: string): Effect.Effect<HttpServerResponse.HttpServerResponse, E, R | HttpServerRequest.HttpServerRequest | RpcSerialization.RpcSerialization>;
|
|
7
9
|
//# sourceMappingURL=local.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../../../src/server/rpc-server/local.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../../../src/server/rpc-server/local.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAqB9C,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,GAAG,OAAO,CAWhF;AACD,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAChC,OAAO,EAAE,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,KAAK,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,kBAAkB,EAAE,CAAC,EAAE,CAAC,CAAC,aAEpG,iBAAiB,CAAC,iBAAiB,gEAIpD;AACD,wBAAgB,gBAAgB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAGpF;AACD,wBAAgB,kBAAkB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAElE;AAiBD,wBAAgB,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,kBAAkB,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,YAAY,CAAC,EAAE,MAAM,wIAW5H"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
2
|
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
3
3
|
import { RpcSerialization } from "effect/rpc";
|
|
4
|
-
import { proxyAddresses, proxyMatches } from "./trustedProxy.js";
|
|
4
|
+
import { proxyAddresses, proxyMatches, proxyRanges } from "./trustedProxy.js";
|
|
5
5
|
function loopbackAddress(address) {
|
|
6
6
|
const unmapped = unmapAddress(address);
|
|
7
7
|
return unmapped === "::1" || (/^127(?:\.\d{1,3}){3}$/u.test(unmapped) && unmapped.split(".").every((part) => Number(part) <= 255));
|
|
@@ -38,8 +38,15 @@ export function loopbackOnly(handler) {
|
|
|
38
38
|
? handler(request)
|
|
39
39
|
: Effect.succeed(HttpServerResponse.text("Only loopback hosts are served", { status: 403 }));
|
|
40
40
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
41
|
+
export function trustedProxyPeer(trustedProxy) {
|
|
42
|
+
const addresses = trustedProxy === undefined ? undefined : proxyAddresses(trustedProxy);
|
|
43
|
+
return (address) => addresses !== undefined && proxyMatches(addresses, address);
|
|
44
|
+
}
|
|
45
|
+
export function trustedProxyRanges(trustedProxy) {
|
|
46
|
+
return trustedProxy === undefined ? [] : (proxyRanges(trustedProxy) ?? []);
|
|
47
|
+
}
|
|
48
|
+
function proxyOrigin(request, isTrustedProxy) {
|
|
49
|
+
if (!Option.exists(request.remoteAddress, isTrustedProxy)) {
|
|
43
50
|
return false;
|
|
44
51
|
}
|
|
45
52
|
const host = request.headers["x-forwarded-host"];
|
|
@@ -56,13 +63,13 @@ function proxyOrigin(request, trustedProxy) {
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
export function localRpcGuard(effect, trustedProxy) {
|
|
59
|
-
const
|
|
66
|
+
const isTrustedProxy = trustedProxyPeer(trustedProxy);
|
|
60
67
|
return Effect.gen(function* () {
|
|
61
68
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
62
69
|
const serialization = yield* RpcSerialization.RpcSerialization;
|
|
63
70
|
const local = loopbackHost(request.headers.host) && Option.exists(request.remoteAddress, loopbackAddress);
|
|
64
71
|
const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
|
|
65
|
-
return ((local && sameOrigin(request)) || proxyOrigin(request,
|
|
72
|
+
return ((local && sameOrigin(request)) || proxyOrigin(request, isTrustedProxy)) && contentType === serialization.contentType
|
|
66
73
|
? yield* effect
|
|
67
74
|
: HttpServerResponse.text("Local same-origin requests only", { status: 403 });
|
|
68
75
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local.js","sourceRoot":"","sources":["../../../src/server/rpc-server/local.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"local.js","sourceRoot":"","sources":["../../../src/server/rpc-server/local.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BlockList } from "node:net";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
|
+
export declare function proxyRanges(value: string): string[] | undefined;
|
|
3
4
|
export declare function proxyAddresses(value: string): BlockList | undefined;
|
|
4
5
|
export declare const TrustedProxy: Schema.String;
|
|
5
6
|
export declare function proxyMatches(addresses: BlockList, address: string): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trustedProxy.d.ts","sourceRoot":"","sources":["../../../src/server/rpc-server/trustedProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAQ,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"trustedProxy.d.ts","sourceRoot":"","sources":["../../../src/server/rpc-server/trustedProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAQ,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAkDhC,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAU/D;AACD,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAGnE;AACD,eAAO,MAAM,YAAY,eAIxB,CAAC;AACF,wBAAgB,YAAY,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAI3E"}
|
|
@@ -20,9 +20,49 @@ function addProxyAddress(addresses, entry) {
|
|
|
20
20
|
}
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
|
+
function mappedIpv4(address) {
|
|
24
|
+
const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
|
|
25
|
+
if (groups?.high === undefined || groups.low === undefined) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const high = Number.parseInt(groups.high, 16);
|
|
29
|
+
const low = Number.parseInt(groups.low, 16);
|
|
30
|
+
return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
|
|
31
|
+
}
|
|
32
|
+
function normalizedProxyRange(entry) {
|
|
33
|
+
const range = new BlockList();
|
|
34
|
+
if (!addProxyAddress(range, entry)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const [address = "", prefix] = entry.split("/");
|
|
38
|
+
if (isIP(address) !== 6) {
|
|
39
|
+
return [entry];
|
|
40
|
+
}
|
|
41
|
+
const ipv4 = mappedIpv4(address);
|
|
42
|
+
const bits = prefix === undefined ? 128 : Number(prefix);
|
|
43
|
+
if (ipv4 !== undefined) {
|
|
44
|
+
if (bits >= 96) {
|
|
45
|
+
return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
|
|
46
|
+
}
|
|
47
|
+
const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
|
|
48
|
+
return [`${network}/${bits}`, "0.0.0.0/0"];
|
|
49
|
+
}
|
|
50
|
+
return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
|
|
51
|
+
}
|
|
52
|
+
export function proxyRanges(value) {
|
|
53
|
+
const ranges = [];
|
|
54
|
+
for (const entry of value.split(",")) {
|
|
55
|
+
const normalized = normalizedProxyRange(entry.trim());
|
|
56
|
+
if (normalized === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
ranges.push(...normalized);
|
|
60
|
+
}
|
|
61
|
+
return ranges;
|
|
62
|
+
}
|
|
23
63
|
export function proxyAddresses(value) {
|
|
24
64
|
const addresses = new BlockList();
|
|
25
|
-
return value
|
|
65
|
+
return proxyRanges(value)?.every((entry) => addProxyAddress(addresses, entry)) ? addresses : undefined;
|
|
26
66
|
}
|
|
27
67
|
export const TrustedProxy = Schema.String.check(Schema.makeFilter((value) => proxyAddresses(value) !== undefined, {
|
|
28
68
|
message: "Expected a comma-separated list of IPv4 or IPv6 addresses or CIDR ranges",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trustedProxy.js","sourceRoot":"","sources":["../../../src/server/rpc-server/trustedProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,SAAS,eAAe,CAAC,SAAoB,EAAE,KAAa;IAC3D,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACtC,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7B,IAAI,MAAM,KAAK,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC/D,OAAO,KAAK,CAAC;IACd,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IAC5C,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACxB,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAC1B,SAAS,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACrC,CAAC;SAAM,CAAC;QACP,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;YAC1E,OAAO,KAAK,CAAC;QACd,CAAC;QACD,SAAS,CAAC,SAAS,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AACD,
|
|
1
|
+
{"version":3,"file":"trustedProxy.js","sourceRoot":"","sources":["../../../src/server/rpc-server/trustedProxy.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Opinionated Effect application infrastructure for ShivaeDev services",
|
|
5
5
|
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/platform#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -168,6 +168,12 @@
|
|
|
168
168
|
}
|
|
169
169
|
},
|
|
170
170
|
"exports": {
|
|
171
|
+
"./boundedBody.ts": {
|
|
172
|
+
"types": "./dist/server/boundedBody.d.ts",
|
|
173
|
+
"source": "./src/server/boundedBody.ts",
|
|
174
|
+
"import": "./dist/server/boundedBody.js",
|
|
175
|
+
"default": "./dist/server/boundedBody.js"
|
|
176
|
+
},
|
|
171
177
|
"./appHost.ts": {
|
|
172
178
|
"types": "./dist/server/appHost.d.ts",
|
|
173
179
|
"source": "./src/server/appHost.ts",
|
|
@@ -326,7 +332,7 @@
|
|
|
326
332
|
],
|
|
327
333
|
"dependencies": {
|
|
328
334
|
"@shivaedev/effect-contract": "0.5.0",
|
|
329
|
-
"@shivaedev/effect-sql": "0.
|
|
335
|
+
"@shivaedev/effect-sql": "0.4.0",
|
|
330
336
|
"@shivaedev/types": "0.2.1"
|
|
331
337
|
},
|
|
332
338
|
"devDependencies": {
|
|
@@ -341,7 +347,7 @@
|
|
|
341
347
|
"@prisma-next/target-postgres": "0.16.0",
|
|
342
348
|
"@shivaedev/effect-prisma": "0.9.0",
|
|
343
349
|
"@shivaedev/effect-trpc": "0.7.0",
|
|
344
|
-
"@shivaedev/quality": "0.21.
|
|
350
|
+
"@shivaedev/quality": "0.21.1",
|
|
345
351
|
"@trpc/client": "11.19.0",
|
|
346
352
|
"@trpc/server": "11.19.0",
|
|
347
353
|
"@types/node": "24.19.1",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
|
|
2
|
+
import { Data, Effect, Stream } from "effect";
|
|
3
|
+
import { HttpIncomingMessage, HttpServerError, type HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
4
|
+
|
|
5
|
+
export class RequestBodyTooLarge
|
|
6
|
+
extends Data.TaggedError("RequestBodyTooLarge")<{
|
|
7
|
+
readonly request: HttpServerRequest.HttpServerRequest;
|
|
8
|
+
readonly limit: bigint;
|
|
9
|
+
}>
|
|
10
|
+
implements HttpServerRespondable.Respondable
|
|
11
|
+
{
|
|
12
|
+
[HttpServerRespondable.symbol]() {
|
|
13
|
+
return Effect.succeed(HttpServerResponse.empty({ status: 413 }));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const readBoundedBody = Effect.fnUntraced(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
18
|
+
const incoming = NodeHttpServerRequest.toIncomingMessage(request);
|
|
19
|
+
const limit = yield* HttpIncomingMessage.MaxBodySize;
|
|
20
|
+
const read = Effect.gen(function* () {
|
|
21
|
+
const length = request.headers["content-length"];
|
|
22
|
+
if (limit !== undefined && length !== undefined && BigInt(length) > limit) {
|
|
23
|
+
return yield* new RequestBodyTooLarge({ limit, request });
|
|
24
|
+
}
|
|
25
|
+
const chunks: Uint8Array[] = [];
|
|
26
|
+
let size = 0n;
|
|
27
|
+
yield* NodeStream.fromReadable<Uint8Array, HttpServerError.HttpServerError>({
|
|
28
|
+
closeOnDone: false,
|
|
29
|
+
evaluate: () => incoming,
|
|
30
|
+
onError: (cause) => new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) }),
|
|
31
|
+
}).pipe(
|
|
32
|
+
Stream.runForEach((chunk) => {
|
|
33
|
+
size += BigInt(chunk.byteLength);
|
|
34
|
+
if (limit !== undefined && size > limit) {
|
|
35
|
+
return Effect.fail(new RequestBodyTooLarge({ limit, request }));
|
|
36
|
+
}
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
return Effect.void;
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
return Uint8Array.from(Buffer.concat(chunks)).buffer;
|
|
42
|
+
});
|
|
43
|
+
// Drain unread bytes without destroying the socket so the server can send a response.
|
|
44
|
+
return yield* read.pipe(Effect.onError(() => Effect.sync(() => incoming.resume())));
|
|
45
|
+
});
|
|
@@ -1,37 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerError, type HttpServerRequest } from "effect/http";
|
|
3
|
+
import { readBoundedBody } from "./boundedBody.ts";
|
|
4
4
|
|
|
5
5
|
export function boundedRpcRequest(request: HttpServerRequest.HttpServerRequest): HttpServerRequest.HttpServerRequest {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const chunks: Uint8Array[] = [];
|
|
13
|
-
let size = 0n;
|
|
14
|
-
yield* NodeStream.fromReadable<Uint8Array, HttpServerError.HttpServerError>({
|
|
15
|
-
closeOnDone: false,
|
|
16
|
-
evaluate: () => incoming,
|
|
17
|
-
onError: error,
|
|
18
|
-
}).pipe(
|
|
19
|
-
Stream.runForEach((chunk) => {
|
|
20
|
-
size += BigInt(chunk.byteLength);
|
|
21
|
-
if (limit !== undefined && size > limit) {
|
|
22
|
-
return Effect.fail(error(new Error("RPC body exceeds MAX_BODY_SIZE")));
|
|
23
|
-
}
|
|
24
|
-
chunks.push(chunk);
|
|
25
|
-
return Effect.void;
|
|
26
|
-
}),
|
|
27
|
-
);
|
|
28
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
29
|
-
}).pipe(
|
|
30
|
-
Effect.onError(() => Effect.sync(() => incoming.resume())),
|
|
6
|
+
const arrayBuffer = readBoundedBody(request).pipe(
|
|
7
|
+
Effect.mapError((cause) =>
|
|
8
|
+
cause._tag === "RequestBodyTooLarge"
|
|
9
|
+
? new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) })
|
|
10
|
+
: cause,
|
|
11
|
+
),
|
|
31
12
|
Effect.cached,
|
|
32
13
|
Effect.runSync,
|
|
33
14
|
);
|
|
34
|
-
const
|
|
15
|
+
const text = Effect.map(arrayBuffer, (body) => new TextDecoder().decode(body));
|
|
35
16
|
// The native text reader destroys the HTTP socket on overflow, preventing a 4xx response.
|
|
36
17
|
return new Proxy(request, {
|
|
37
18
|
get: (target, key, receiver) => {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { BlockList } from "node:net";
|
|
2
1
|
import { Effect, Option } from "effect";
|
|
3
2
|
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
3
|
import { RpcSerialization } from "effect/rpc";
|
|
5
|
-
import { proxyAddresses, proxyMatches } from "./trustedProxy.ts";
|
|
4
|
+
import { proxyAddresses, proxyMatches, proxyRanges } from "./trustedProxy.ts";
|
|
6
5
|
|
|
7
6
|
function loopbackAddress(address: string): boolean {
|
|
8
7
|
const unmapped = unmapAddress(address);
|
|
@@ -42,8 +41,15 @@ export function loopbackOnly<E, R>(
|
|
|
42
41
|
? handler(request)
|
|
43
42
|
: Effect.succeed(HttpServerResponse.text("Only loopback hosts are served", { status: 403 }));
|
|
44
43
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
44
|
+
export function trustedProxyPeer(trustedProxy?: string): (address: string) => boolean {
|
|
45
|
+
const addresses = trustedProxy === undefined ? undefined : proxyAddresses(trustedProxy);
|
|
46
|
+
return (address) => addresses !== undefined && proxyMatches(addresses, address);
|
|
47
|
+
}
|
|
48
|
+
export function trustedProxyRanges(trustedProxy?: string): string[] {
|
|
49
|
+
return trustedProxy === undefined ? [] : (proxyRanges(trustedProxy) ?? []);
|
|
50
|
+
}
|
|
51
|
+
function proxyOrigin(request: HttpServerRequest.HttpServerRequest, isTrustedProxy: (address: string) => boolean): boolean {
|
|
52
|
+
if (!Option.exists(request.remoteAddress, isTrustedProxy)) {
|
|
47
53
|
return false;
|
|
48
54
|
}
|
|
49
55
|
const host = request.headers["x-forwarded-host"];
|
|
@@ -59,13 +65,13 @@ function proxyOrigin(request: HttpServerRequest.HttpServerRequest, trustedProxy:
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
export function localRpcGuard<E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, trustedProxy?: string) {
|
|
62
|
-
const
|
|
68
|
+
const isTrustedProxy = trustedProxyPeer(trustedProxy);
|
|
63
69
|
return Effect.gen(function* () {
|
|
64
70
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
65
71
|
const serialization = yield* RpcSerialization.RpcSerialization;
|
|
66
72
|
const local = loopbackHost(request.headers.host) && Option.exists(request.remoteAddress, loopbackAddress);
|
|
67
73
|
const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
|
|
68
|
-
return ((local && sameOrigin(request)) || proxyOrigin(request,
|
|
74
|
+
return ((local && sameOrigin(request)) || proxyOrigin(request, isTrustedProxy)) && contentType === serialization.contentType
|
|
69
75
|
? yield* effect
|
|
70
76
|
: HttpServerResponse.text("Local same-origin requests only", { status: 403 });
|
|
71
77
|
});
|
|
@@ -20,9 +20,49 @@ function addProxyAddress(addresses: BlockList, entry: string): boolean {
|
|
|
20
20
|
}
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
|
+
function mappedIpv4(address: string): string | undefined {
|
|
24
|
+
const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
|
|
25
|
+
if (groups?.high === undefined || groups.low === undefined) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const high = Number.parseInt(groups.high, 16);
|
|
29
|
+
const low = Number.parseInt(groups.low, 16);
|
|
30
|
+
return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
|
|
31
|
+
}
|
|
32
|
+
function normalizedProxyRange(entry: string): string[] | undefined {
|
|
33
|
+
const range = new BlockList();
|
|
34
|
+
if (!addProxyAddress(range, entry)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const [address = "", prefix] = entry.split("/");
|
|
38
|
+
if (isIP(address) !== 6) {
|
|
39
|
+
return [entry];
|
|
40
|
+
}
|
|
41
|
+
const ipv4 = mappedIpv4(address);
|
|
42
|
+
const bits = prefix === undefined ? 128 : Number(prefix);
|
|
43
|
+
if (ipv4 !== undefined) {
|
|
44
|
+
if (bits >= 96) {
|
|
45
|
+
return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
|
|
46
|
+
}
|
|
47
|
+
const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
|
|
48
|
+
return [`${network}/${bits}`, "0.0.0.0/0"];
|
|
49
|
+
}
|
|
50
|
+
return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
|
|
51
|
+
}
|
|
52
|
+
export function proxyRanges(value: string): string[] | undefined {
|
|
53
|
+
const ranges: string[] = [];
|
|
54
|
+
for (const entry of value.split(",")) {
|
|
55
|
+
const normalized = normalizedProxyRange(entry.trim());
|
|
56
|
+
if (normalized === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
ranges.push(...normalized);
|
|
60
|
+
}
|
|
61
|
+
return ranges;
|
|
62
|
+
}
|
|
23
63
|
export function proxyAddresses(value: string): BlockList | undefined {
|
|
24
64
|
const addresses = new BlockList();
|
|
25
|
-
return value
|
|
65
|
+
return proxyRanges(value)?.every((entry) => addProxyAddress(addresses, entry)) ? addresses : undefined;
|
|
26
66
|
}
|
|
27
67
|
export const TrustedProxy = Schema.String.check(
|
|
28
68
|
Schema.makeFilter((value) => proxyAddresses(value) !== undefined, {
|