Files
lda-wf/web/packages/rpc/src/target-policy.ts
T
lda Verified e149a39b9e feat(rpc): add target policy, protocol schemas, and tagged errors
- Add normalizeLoopbackTarget() with loopback-only URL validation
- Add JSON-RPC response decoder with schema validation
- Define 8 tagged errors using Data.TaggedError
- 20 tests covering target policy and protocol decoding
- Re-export all public types from index.ts
2026-07-02 11:16:29 +07:00

58 lines
1.6 KiB
TypeScript

import { InvalidTargetError } from "./errors.js";
export { InvalidTargetError };
const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
/**
* Normalize and validate a loopback RPC target URL.
*
* Only `http://127.0.0.1`, `http://localhost`, and `http://[::1]` are accepted.
* DNS names other than the literal `localhost` are rejected to avoid
* DNS-rebinding ambiguity — a hostname that resolves to a non-loopback
* address at resolution time would bypass this check.
*/
export function normalizeLoopbackTarget(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new InvalidTargetError({ message: "invalid URL" });
}
if (url.protocol !== "http:") {
throw new InvalidTargetError({ message: "only http: protocol is allowed" });
}
if (!ALLOWED_HOSTNAMES.has(url.hostname)) {
throw new InvalidTargetError({
message: `hostname "${url.hostname}" is not an allowed loopback address`,
});
}
if (url.username !== "" || url.password !== "") {
throw new InvalidTargetError({ message: "credentials in URL are not allowed" });
}
if (url.port === "") {
throw new InvalidTargetError({ message: "explicit port is required" });
}
const port = Number(url.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new InvalidTargetError({
message: `port ${url.port} is outside valid range 1..65535`,
});
}
if (url.search !== "") {
throw new InvalidTargetError({ message: "query string is not allowed" });
}
if (url.hash !== "") {
throw new InvalidTargetError({ message: "fragment is not allowed" });
}
return url.toString();
}