-
-
Notifications
You must be signed in to change notification settings - Fork 41
fix(orb): revalidate relay DNS before forwarding #1395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
627c609
33e2528
e24bc91
394aad9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,36 @@ import { hashToken } from "../auth/security"; | |
| import { isSafeHttpUrl } from "../review/content-lane/safe-url"; | ||
| import { decryptSecret, encryptSecret } from "../utils/crypto"; | ||
|
|
||
| type RelayDnsResolver = (hostname: string) => Promise<string[]>; | ||
|
|
||
| function addressAsUrlHost(address: string): string { | ||
| return address.includes(":") ? `[${address.replace(/^\[|\]$/g, "")}]` : address; | ||
| } | ||
|
|
||
| function resolvedAddressIsSafe(address: string): boolean { | ||
| return isSafeHttpUrl(`https://${addressAsUrlHost(address)}/`); | ||
| } | ||
|
|
||
| async function resolveRelayHostname(hostname: string): Promise<string[]> { | ||
| if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) return [hostname]; | ||
| const answers: string[] = []; | ||
| for (const type of ["A", "AAAA"]) { | ||
| const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=${type}`, { headers: { accept: "application/dns-json" }, signal: AbortSignal.timeout(3_000) }); | ||
| if (!res.ok) throw new Error("dns_resolution_failed"); | ||
| const json = (await res.json()) as { Answer?: { data?: string; type?: number }[] }; | ||
| for (const answer of json.Answer ?? []) { | ||
| if ((answer.type === 1 || answer.type === 28) && answer.data) answers.push(answer.data); | ||
| } | ||
| } | ||
| return answers; | ||
| } | ||
|
|
||
| async function relayDestinationIsSafe(raw: string, resolveHostname: RelayDnsResolver): Promise<boolean> { | ||
| if (!isSafeHttpUrl(raw)) return false; | ||
| const addresses = await resolveHostname(new URL(raw).hostname.toLowerCase()); | ||
| return addresses.length > 0 && addresses.every(resolvedAddressIsSafe); | ||
| } | ||
|
|
||
| // The events a brokered container needs to review/act on. Installation-lifecycle + other Orb-internal events are | ||
| // deliberately NOT forwarded (the container runs under the CENTRAL Orb App, not its own, so it must not treat | ||
| // those as its own installation state). | ||
|
|
@@ -104,7 +134,7 @@ export async function storeRelayFailure( | |
| * Each row gets up to RELAY_RETRY_MAX_ATTEMPTS (5) retries within a 1-hour TTL; on success or expiry the row | ||
| * is removed. Never throws — a bad DB row or a persistently-down container is dropped (with an alertable log, | ||
| * below) after exhaustion. */ | ||
| export async function retryFailedRelays(env: Env, opts?: { fetchImpl?: typeof fetch }): Promise<void> { | ||
| export async function retryFailedRelays(env: Env, opts?: { fetchImpl?: typeof fetch; resolveHostname?: RelayDnsResolver }): Promise<void> { | ||
| // Prune rows whose TTL has elapsed or whose attempt budget is exhausted. | ||
| const pruned = await env.DB | ||
| .prepare("DELETE FROM orb_relay_failures WHERE expires_at < datetime('now') OR attempts >= ?") | ||
|
|
@@ -129,6 +159,7 @@ export async function retryFailedRelays(env: Env, opts?: { fetchImpl?: typeof fe | |
| env, | ||
| { eventName: row.event_name, installationId: row.installation_id, deliveryId: row.delivery_id, rawBody: row.raw_body }, | ||
| opts?.fetchImpl, | ||
| opts?.resolveHostname, | ||
| ); | ||
| if (result === "forwarded" || result === "skipped") { | ||
| await env.DB.prepare("DELETE FROM orb_relay_failures WHERE delivery_id = ?").bind(row.delivery_id).run(); | ||
|
|
@@ -154,6 +185,7 @@ export async function forwardOrbEvent( | |
| env: Env, | ||
| args: { eventName: string; installationId: number | null | undefined; deliveryId: string; rawBody: string }, | ||
| fetchImpl: typeof fetch = fetch, | ||
| resolveHostname: RelayDnsResolver = resolveRelayHostname, | ||
| ): Promise<"forwarded" | "skipped" | "failed"> { | ||
| if (!args.installationId || !RELAY_FORWARD_EVENTS.has(args.eventName)) return "skipped"; | ||
| const row = await env.DB | ||
|
|
@@ -162,6 +194,7 @@ export async function forwardOrbEvent( | |
| .first<{ relay_url: string; relay_secret_enc: string; relay_secret_iv: string; relay_secret_salt: string | null }>(); | ||
| if (!row || !env.TOKEN_ENCRYPTION_SECRET) return "skipped"; | ||
| try { | ||
| if (!(await relayDestinationIsSafe(row.relay_url, resolveHostname))) return "failed"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: DNS revalidation uses DoH but fetch still resolves hostname, leaving TOCTOU rebinding risk DoH IP check does not prevent rebinding because fetchImpl re-resolves the hostname at connection time. Connect to the validated resolved IP directly, preserving Host header for TLS, instead of passing the hostname to fetch. AI prompt |
||
| const secret = await decryptSecret(row.relay_secret_enc, row.relay_secret_iv, env.TOKEN_ENCRYPTION_SECRET, row.relay_secret_salt); | ||
| const signature = await relaySignature(secret, args.rawBody); | ||
| const res = await fetchImpl(row.relay_url, { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Relay hostname validation leaks internal hostnames to external DNS resolver
Every relay target hostname is sent to Cloudflare's public DoH resolver, leaking internal infrastructure names to a third party.
Use a configurable or local DNS resolver instead of hardcoding cloudflare-dns.com.
AI prompt