| 1 | /** |
| 2 | * Pure checks behind T3 Connect link proofs and relay requests: which local |
| 3 | * origin a link may point at, and which scopes and lifetimes a proof claims and |
| 4 | * accepts. |
| 5 | */ |
| 6 | import type { |
| 7 | RelayEnvironmentLinkProofPayload, |
| 8 | RelayLinkProofRequest, |
| 9 | RelayManagedEndpointOrigin, |
| 10 | RelayManagedEndpointRuntimeConfig, |
| 11 | } from "@t3tools/contracts/relay"; |
| 12 | import type { HttpServerRequest } from "effect/http"; |
| 13 | |
| 14 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; |
| 15 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; |
| 16 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 17 | |
| 18 | function normalizeHostname(hostname: string): string { |
| 19 | return hostname |
| 20 | .trim() |
| 21 | .toLowerCase() |
| 22 | .replace(/^\[(.*)\]$/, "$1"); |
| 23 | } |
| 24 | |
| 25 | function isLoopbackHostname(hostname: string): boolean { |
| 26 | return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); |
| 27 | } |
| 28 | |
| 29 | function firstForwardedHeaderValue(value: string | undefined): string | undefined { |
| 30 | const first = value?.split(",")[0]?.trim(); |
| 31 | return first && first.length > 0 ? first : undefined; |
| 32 | } |
| 33 | |
| 34 | export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { |
| 35 | try { |
| 36 | return new URL(request.originalUrl).href; |
| 37 | } catch { |
| 38 | const host = firstForwardedHeaderValue(request.headers.host) ?? "127.0.0.1"; |
| 39 | try { |
| 40 | return new URL(request.originalUrl, `http://${host}`).href; |
| 41 | } catch { |
| 42 | return null; |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | export function hasForwardedAuthorityHeaders( |
| 48 | request: HttpServerRequest.HttpServerRequest, |
| 49 | ): boolean { |
| 50 | return ( |
| 51 | firstForwardedHeaderValue(request.headers["x-forwarded-host"]) !== undefined || |
| 52 | firstForwardedHeaderValue(request.headers["x-forwarded-proto"]) !== undefined |
| 53 | ); |
| 54 | } |
| 55 | |
| 56 | function endpointRequestPort(url: URL): number { |
| 57 | return Number(url.port || (url.protocol === "https:" ? 443 : 80)); |
| 58 | } |
| 59 | |
| 60 | export function parseManagedEndpointLocalOrigin(localOrigin: string) { |
| 61 | const url = new URL(localOrigin); |
| 62 | if ( |
| 63 | localOrigin !== localOrigin.trim() || |
| 64 | (url.protocol !== "http:" && url.protocol !== "https:") || |
| 65 | url.username !== "" || |
| 66 | url.password !== "" || |
| 67 | url.pathname !== "/" || |
| 68 | url.search !== "" || |
| 69 | url.hash !== "" || |
| 70 | localOrigin.includes("?") || |
| 71 | localOrigin.includes("#") |
| 72 | ) { |
| 73 | throw new Error("Invalid local origin"); |
| 74 | } |
| 75 | const wsUrl = new URL(url.origin); |
| 76 | wsUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:"; |
| 77 | return { |
| 78 | httpBaseUrl: url.origin, |
| 79 | wsBaseUrl: wsUrl.origin, |
| 80 | origin: { |
| 81 | localHttpHost: url.hostname, |
| 82 | localHttpPort: endpointRequestPort(url), |
| 83 | } satisfies RelayManagedEndpointOrigin, |
| 84 | }; |
| 85 | } |
| 86 | |
| 87 | export function isAllowedEndpointOrigin(input: { |
| 88 | readonly origin: RelayManagedEndpointOrigin; |
| 89 | readonly requestUrl: string; |
| 90 | }): boolean { |
| 91 | if (!isLoopbackHostname(input.origin.localHttpHost)) { |
| 92 | return false; |
| 93 | } |
| 94 | |
| 95 | const url = new URL(input.requestUrl); |
| 96 | if (!isLoopbackHostname(url.hostname)) { |
| 97 | return false; |
| 98 | } |
| 99 | |
| 100 | return input.origin.localHttpPort === endpointRequestPort(url); |
| 101 | } |
| 102 | |
| 103 | // A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must |
| 104 | // point at a loopback origin. A manual endpoint is reached out of band (e.g. |
| 105 | // Tailscale) or not advertised at all for publish-only links, so it is not |
| 106 | // tied to the managed-tunnel scope. |
| 107 | export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean { |
| 108 | return ( |
| 109 | request.endpoint.providerKind === "cloudflare_tunnel" || |
| 110 | request.endpoint.providerKind === "manual" |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | export function linkProofScopes( |
| 115 | request: RelayLinkProofRequest, |
| 116 | ): RelayEnvironmentLinkProofPayload["scopes"] { |
| 117 | return request.endpoint.providerKind === "cloudflare_tunnel" |
| 118 | ? ["agent_activity_notifications", "managed_tunnels"] |
| 119 | : ["agent_activity_notifications"]; |
| 120 | } |
| 121 | |
| 122 | export function hasExactScope(input: { |
| 123 | readonly scopes: ReadonlyArray<string>; |
| 124 | readonly expected: string; |
| 125 | }): boolean { |
| 126 | return input.scopes.length === 1 && input.scopes[0] === input.expected; |
| 127 | } |
| 128 | |
| 129 | export function hasBoundedCloudProofLifetime(input: { |
| 130 | readonly iat: number; |
| 131 | readonly exp: number; |
| 132 | readonly nowSeconds: number; |
| 133 | }): boolean { |
| 134 | return ( |
| 135 | input.exp > input.iat && |
| 136 | input.exp - input.iat <= CLOUD_PROOF_MAX_LIFETIME_SECONDS && |
| 137 | input.iat <= input.nowSeconds + CLOUD_PROOF_CLOCK_SKEW_SECONDS |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | export function managedEndpointRuntimeConfigsMatch( |
| 142 | left: RelayManagedEndpointRuntimeConfig, |
| 143 | right: RelayManagedEndpointRuntimeConfig, |
| 144 | ): boolean { |
| 145 | return ( |
| 146 | left.providerKind === right.providerKind && |
| 147 | left.connectorToken === right.connectorToken && |
| 148 | left.tunnelId === right.tunnelId && |
| 149 | left.tunnelName === right.tunnelName |
| 150 | ); |
| 151 | } |