From 55dcf4e1c3b76806eb91c4239f4ad2ac718de388 Mon Sep 17 00:00:00 2001 From: HaykK-Solicy Date: Mon, 1 Jun 2026 18:56:34 +0400 Subject: [PATCH 1/4] feat(identity): verify website ownership for domain identities --- src/libs/abstraction/index.ts | 37 ++++++++ src/libs/abstraction/web2/domain.ts | 92 +++++++++++++++++++ src/libs/abstraction/web2/parsers.ts | 1 + src/libs/network/handlers/identityHandlers.ts | 41 +++++++++ src/libs/omniprotocol/protocol/opcodes.ts | 1 + src/libs/omniprotocol/protocol/registry.ts | 6 ++ 6 files changed, 178 insertions(+) create mode 100644 src/libs/abstraction/web2/domain.ts diff --git a/src/libs/abstraction/index.ts b/src/libs/abstraction/index.ts index 8902d00bb..e8af73dcd 100644 --- a/src/libs/abstraction/index.ts +++ b/src/libs/abstraction/index.ts @@ -1,6 +1,7 @@ import { GithubProofParser } from "./web2/github" import { TwitterProofParser } from "./web2/twitter" import { DiscordProofParser } from "./web2/discord" +import { DomainProofParser, DOMAIN_PROOF_PATH } from "./web2/domain" import { type Web2ProofParser } from "./web2/parsers" import { Web2CoreTargetIdentityPayload } from "@kynesyslabs/demosdk/abstraction" import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" @@ -180,6 +181,7 @@ export async function verifyWeb2Proof( | typeof TwitterProofParser | typeof GithubProofParser | typeof DiscordProofParser + | typeof DomainProofParser switch (payload.context) { case "twitter": @@ -194,6 +196,41 @@ export async function verifyWeb2Proof( case "discord": parser = DiscordProofParser break + case "domain": { + // The proof must be the well-known file ON the claimed domain. + // Binding the proof URL's host to the claimed hostname is what stops + // a sender from pointing at someone else's (their own) valid proof + // while claiming an unrelated domain. + let proofUrl: URL + try { + proofUrl = new URL(payload.proof as string) + } catch { + return { + success: false, + message: "Invalid domain proof URL", + } + } + if (proofUrl.protocol !== "https:") { + return { + success: false, + message: "Domain proof URL must use https", + } + } + if (proofUrl.pathname !== DOMAIN_PROOF_PATH) { + return { + success: false, + message: `Domain proof must be hosted at ${DOMAIN_PROOF_PATH}`, + } + } + if (proofUrl.hostname !== payload.username) { + return { + success: false, + message: "Proof host does not match the claimed domain", + } + } + parser = DomainProofParser + break + } default: return { success: false, diff --git a/src/libs/abstraction/web2/domain.ts b/src/libs/abstraction/web2/domain.ts new file mode 100644 index 000000000..4c9cf4aad --- /dev/null +++ b/src/libs/abstraction/web2/domain.ts @@ -0,0 +1,92 @@ +import axios from "axios" +import https from "https" +import { Web2ProofParser } from "./parsers" +import { SigningAlgorithm } from "@kynesyslabs/demosdk/types" +import SharedState from "@/utilities/sharedState" +import log from "src/utilities/logger" + +/** The well-known path a domain owner hosts to prove control. */ +export const DOMAIN_PROOF_PATH = "/.well-known/demos-cci.txt" + +/** Max bytes we read from a well-known file — the proof payload is tiny. */ +const MAX_PROOF_BYTES = 4096 + +/** + * Fetch a domain ownership proof file over HTTPS. + * + * The TLS certificate, validated during the handshake, binds the response to + * the requested hostname — so a successful fetch is itself proof that the + * content was served from a host presenting a valid cert for that domain. + * (Certificate validation is enabled in production and relaxed in dev so local + * self-signed hosts can be tested.) + * + * @param url Full proof URL, e.g. https://example.com/.well-known/demos-cci.txt + * @returns The trimmed file body and the verified hostname. + */ +export async function fetchDomainProof( + url: string, +): Promise<{ hostname: string; body: string }> { + const parsed = new URL(url) + if (parsed.protocol !== "https:") { + throw new Error("Domain proof URL must use https") + } + + const verifyCertificates = SharedState.getInstance().PROD + const agent = new https.Agent({ rejectUnauthorized: verifyCertificates }) + + const response = await axios.get(url, { + httpsAgent: agent, + responseType: "text", + maxContentLength: MAX_PROOF_BYTES, + maxRedirects: 0, + timeout: 10_000, + // The proof file is plain text; never follow it as JSON. + transformResponse: r => r, + headers: { Accept: "text/plain" }, + }) + + const body = + typeof response.data === "string" + ? response.data.trim() + : String(response.data).trim() + + return { hostname: parsed.hostname, body } +} + +export class DomainProofParser extends Web2ProofParser { + private static instance: DomainProofParser + + constructor() { + super() + } + + async readData( + proofUrl: string, + ): Promise<{ message: string; type: SigningAlgorithm; signature: string }> { + this.verifyProofFormat(proofUrl, "domain") + + let body: string + try { + ;({ body } = await fetchDomainProof(proofUrl)) + } catch (error) { + log.error("[DOMAIN] Failed to fetch proof: " + error) + throw new Error( + `Failed to read domain proof at ${proofUrl}`, + ) + } + + const payload = this.parsePayload(body) + if (!payload) { + throw new Error("Invalid domain proof format") + } + + return payload + } + + static async getInstance() { + if (!this.instance) { + this.instance = new this() + } + return this.instance + } +} diff --git a/src/libs/abstraction/web2/parsers.ts b/src/libs/abstraction/web2/parsers.ts index 134926acc..5d53cb94e 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -15,6 +15,7 @@ export abstract class Web2ProofParser { "https://canary.discord.com/channels", "https://discordapp.com/channels", ], + domain: ["https://"], } constructor() {} diff --git a/src/libs/network/handlers/identityHandlers.ts b/src/libs/network/handlers/identityHandlers.ts index 02dbcbee8..765a54fbb 100644 --- a/src/libs/network/handlers/identityHandlers.ts +++ b/src/libs/network/handlers/identityHandlers.ts @@ -1,5 +1,6 @@ import { Twitter } from "../../identity/tools/twitter" import { Discord } from "../../identity/tools/discord" +import { fetchDomainProof, DOMAIN_PROOF_PATH } from "../../abstraction/web2/domain" import { UDIdentityManager } from "../../blockchain/gcr/gcr_routines/udIdentityManager" import ensureGCRForUser from "../../blockchain/gcr/gcr_routines/ensureGCRForUser" import type { Tweet } from "@kynesyslabs/demosdk/types" @@ -159,6 +160,46 @@ export const identityHandlers: Record = { return response }, + getDomainProof: async (data, response) => { + if (!data.url) { + response.result = 400 + response.response = { success: false, error: "No url specified" } + return response + } + + let parsed: URL + try { + parsed = new URL(data.url) + } catch { + response.result = 400 + response.response = { success: false, error: "Invalid url" } + return response + } + + if (parsed.pathname !== DOMAIN_PROOF_PATH) { + response.result = 400 + response.response = { + success: false, + error: `Proof must be hosted at ${DOMAIN_PROOF_PATH}`, + } + return response + } + + try { + const { hostname, body } = await fetchDomainProof(data.url) + response.result = 200 + response.response = { success: true, hostname, body } + } catch (error) { + log.error("[getDomainProof] " + error) + response.result = 400 + response.response = { + success: false, + error: "Failed to fetch domain proof", + } + } + return response + }, + resolveUdDomain: async (data, response) => { try { const res = await UDIdentityManager.resolveUDDomain(data.domain) diff --git a/src/libs/omniprotocol/protocol/opcodes.ts b/src/libs/omniprotocol/protocol/opcodes.ts index caa585e4a..41b6d546d 100644 --- a/src/libs/omniprotocol/protocol/opcodes.ts +++ b/src/libs/omniprotocol/protocol/opcodes.ts @@ -62,6 +62,7 @@ export enum OmniOpcode { WEB2_PROXY_REQUEST = 0x52, GET_TWEET = 0x53, GET_DISCORD_MESSAGE = 0x54, + GET_DOMAIN_PROOF = 0x55, // 0x6X Admin Operations ADMIN_RATE_LIMIT_UNBLOCK = 0x60, diff --git a/src/libs/omniprotocol/protocol/registry.ts b/src/libs/omniprotocol/protocol/registry.ts index 328ab7a99..26067d779 100644 --- a/src/libs/omniprotocol/protocol/registry.ts +++ b/src/libs/omniprotocol/protocol/registry.ts @@ -402,6 +402,12 @@ const DESCRIPTORS: HandlerDescriptor[] = [ authRequired: false, handler: createHttpFallbackHandler(), }, + { + opcode: OmniOpcode.GET_DOMAIN_PROOF, + name: "getDomainProof", + authRequired: false, + handler: createHttpFallbackHandler(), + }, // 0x6X Admin { From ac0eb44f2f19e3026b0e7fc250722023cc277399 Mon Sep 17 00:00:00 2001 From: HaykK-Solicy Date: Tue, 2 Jun 2026 14:24:08 +0400 Subject: [PATCH 2/4] fix(identity): address review feedback on domain identity verification - add SSRF guard to fetchDomainProof: resolve all A/AAAA records and reject private/loopback/link-local/metadata/CGNAT targets before the request (link-local/metadata blocked in all envs; loopback/private dev-only) - getDomainProof: explicit https protocol check with a clear error - verifyWeb2Proof: case-insensitive proof-host vs claimed-domain comparison - domain proof fetch: preserve real error message/detail in logs + rethrow - getDomainProof: structured error logging instead of string concat - clarify the "domain" parser format only checks scheme (binding enforced upstream) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libs/abstraction/index.ts | 4 +- src/libs/abstraction/web2/domain.ts | 75 +++++++++++++++++-- src/libs/abstraction/web2/parsers.ts | 2 + src/libs/network/handlers/identityHandlers.ts | 11 ++- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/libs/abstraction/index.ts b/src/libs/abstraction/index.ts index e8af73dcd..fd6052f83 100644 --- a/src/libs/abstraction/index.ts +++ b/src/libs/abstraction/index.ts @@ -222,7 +222,9 @@ export async function verifyWeb2Proof( message: `Domain proof must be hosted at ${DOMAIN_PROOF_PATH}`, } } - if (proofUrl.hostname !== payload.username) { + // proofUrl.hostname is already lower-cased by the URL parser; + // normalise the client-supplied username so casing never mismatches. + if (proofUrl.hostname !== payload.username?.toLowerCase()) { return { success: false, message: "Proof host does not match the claimed domain", diff --git a/src/libs/abstraction/web2/domain.ts b/src/libs/abstraction/web2/domain.ts index 4c9cf4aad..1432b1039 100644 --- a/src/libs/abstraction/web2/domain.ts +++ b/src/libs/abstraction/web2/domain.ts @@ -1,5 +1,7 @@ import axios from "axios" import https from "https" +import dns from "dns" +import ipaddr from "ipaddr.js" import { Web2ProofParser } from "./parsers" import { SigningAlgorithm } from "@kynesyslabs/demosdk/types" import SharedState from "@/utilities/sharedState" @@ -11,14 +13,66 @@ export const DOMAIN_PROOF_PATH = "/.well-known/demos-cci.txt" /** Max bytes we read from a well-known file — the proof payload is tiny. */ const MAX_PROOF_BYTES = 4096 +/** Non-public ranges tolerated only outside production (for local testing). */ +const DEV_ALLOWED_RANGES = new Set(["loopback", "private", "uniqueLocal"]) + +/** + * SSRF guard for the attacker-controlled proof URL. + * + * The proof URL is supplied by the caller, so without this a request could be + * pointed at internal services or the cloud-metadata endpoint + * (e.g. 169.254.169.254) and the response read back. We resolve every A/AAAA + * record and reject non-public targets: + * + * - link-local (incl. metadata), multicast, broadcast, reserved and CGNAT are + * blocked in ALL environments; + * - loopback / private / unique-local are blocked in production but allowed + * in dev, so local hosts (e.g. localhost) stay testable. + * + * @param allowLocal Permit loopback/private/unique-local (true outside prod). + */ +async function assertAllowedHostname( + hostname: string, + allowLocal: boolean, +): Promise { + let resolved: { address: string }[] + try { + resolved = await dns.promises.lookup(hostname, { all: true }) + } catch { + throw new Error(`Could not resolve host: ${hostname}`) + } + if (resolved.length === 0) { + throw new Error(`Could not resolve host: ${hostname}`) + } + + for (const { address } of resolved) { + let addr = ipaddr.parse(address) + // Classify the embedded v4 for IPv4-mapped IPv6 (::ffff:a.b.c.d). + if ( + addr.kind() === "ipv6" && + (addr as ipaddr.IPv6).isIPv4MappedAddress() + ) { + addr = (addr as ipaddr.IPv6).toIPv4Address() + } + const range = addr.range() + if (range === "unicast") continue + if (allowLocal && DEV_ALLOWED_RANGES.has(range)) continue + throw new Error( + `Refusing to fetch from non-public address: ${hostname} (${address}, ${range})`, + ) + } +} + /** * Fetch a domain ownership proof file over HTTPS. * * The TLS certificate, validated during the handshake, binds the response to * the requested hostname — so a successful fetch is itself proof that the * content was served from a host presenting a valid cert for that domain. - * (Certificate validation is enabled in production and relaxed in dev so local - * self-signed hosts can be tested.) + * + * In production, certificate validation and the SSRF public-address check are + * enforced; both are relaxed in dev so local self-signed hosts (e.g. localhost) + * can be tested. * * @param url Full proof URL, e.g. https://example.com/.well-known/demos-cci.txt * @returns The trimmed file body and the verified hostname. @@ -31,8 +85,13 @@ export async function fetchDomainProof( throw new Error("Domain proof URL must use https") } - const verifyCertificates = SharedState.getInstance().PROD - const agent = new https.Agent({ rejectUnauthorized: verifyCertificates }) + const isProd = SharedState.getInstance().PROD + + // SSRF guard: block internal / metadata targets. Loopback/private hosts are + // permitted only in dev so local test servers (localhost) remain reachable. + await assertAllowedHostname(parsed.hostname, !isProd) + + const agent = new https.Agent({ rejectUnauthorized: isProd }) const response = await axios.get(url, { httpsAgent: agent, @@ -69,9 +128,13 @@ export class DomainProofParser extends Web2ProofParser { try { ;({ body } = await fetchDomainProof(proofUrl)) } catch (error) { - log.error("[DOMAIN] Failed to fetch proof: " + error) + const errorMsg = + error instanceof Error ? error.message : String(error) + log.error( + `[DOMAIN] Failed to fetch proof for ${proofUrl}: ${errorMsg}`, + ) throw new Error( - `Failed to read domain proof at ${proofUrl}`, + `Failed to read domain proof at ${proofUrl}: ${errorMsg}`, ) } diff --git a/src/libs/abstraction/web2/parsers.ts b/src/libs/abstraction/web2/parsers.ts index 5d53cb94e..d1bdcd08a 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -15,6 +15,8 @@ export abstract class Web2ProofParser { "https://canary.discord.com/channels", "https://discordapp.com/channels", ], + // Only enforces the scheme here; the hostname<->claimed-domain binding + // and the exact DOMAIN_PROOF_PATH are validated upstream in verifyWeb2Proof. domain: ["https://"], } diff --git a/src/libs/network/handlers/identityHandlers.ts b/src/libs/network/handlers/identityHandlers.ts index 765a54fbb..a77b60db4 100644 --- a/src/libs/network/handlers/identityHandlers.ts +++ b/src/libs/network/handlers/identityHandlers.ts @@ -176,6 +176,15 @@ export const identityHandlers: Record = { return response } + if (parsed.protocol !== "https:") { + response.result = 400 + response.response = { + success: false, + error: "Proof URL must use https", + } + return response + } + if (parsed.pathname !== DOMAIN_PROOF_PATH) { response.result = 400 response.response = { @@ -190,7 +199,7 @@ export const identityHandlers: Record = { response.result = 200 response.response = { success: true, hostname, body } } catch (error) { - log.error("[getDomainProof] " + error) + log.error("[getDomainProof] failed to fetch domain proof", error) response.result = 400 response.response = { success: false, From dc3f2957cfd05e1af5218d8e604e533dbccecf2b Mon Sep 17 00:00:00 2001 From: HaykK-Solicy Date: Tue, 2 Jun 2026 14:51:49 +0400 Subject: [PATCH 3/4] fixed comments related DNS-rebinding --- src/libs/abstraction/web2/domain.ts | 35 ++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/libs/abstraction/web2/domain.ts b/src/libs/abstraction/web2/domain.ts index 1432b1039..39fd3f58a 100644 --- a/src/libs/abstraction/web2/domain.ts +++ b/src/libs/abstraction/web2/domain.ts @@ -31,11 +31,11 @@ const DEV_ALLOWED_RANGES = new Set(["loopback", "private", "uniqueLocal"]) * * @param allowLocal Permit loopback/private/unique-local (true outside prod). */ -async function assertAllowedHostname( +async function resolveAndValidateHost( hostname: string, allowLocal: boolean, -): Promise { - let resolved: { address: string }[] +): Promise<{ address: string; family: number }> { + let resolved: { address: string; family: number }[] try { resolved = await dns.promises.lookup(hostname, { all: true }) } catch { @@ -61,6 +61,10 @@ async function assertAllowedHostname( `Refusing to fetch from non-public address: ${hostname} (${address}, ${range})`, ) } + + // Return the first validated address so the caller can pin the socket to it + // (prevents DNS-rebinding between this check and connect time). + return resolved[0] } /** @@ -89,9 +93,28 @@ export async function fetchDomainProof( // SSRF guard: block internal / metadata targets. Loopback/private hosts are // permitted only in dev so local test servers (localhost) remain reachable. - await assertAllowedHostname(parsed.hostname, !isProd) - - const agent = new https.Agent({ rejectUnauthorized: isProd }) + const pinned = await resolveAndValidateHost(parsed.hostname, !isProd) + + // Pin the socket to the validated IP so DNS cannot rebind to an internal + // address between the check above and connect time. The URL still carries + // the original hostname, so the Host header and TLS SNI / cert validation + // are unchanged; only the address the socket dials is forced. + const agent = new https.Agent({ + rejectUnauthorized: isProd, + lookup: ( + _hostname: string, + options: { all?: boolean }, + callback: (...args: any[]) => void, + ) => { + if (options && options.all) { + callback(null, [ + { address: pinned.address, family: pinned.family }, + ]) + } else { + callback(null, pinned.address, pinned.family) + } + }, + } as https.AgentOptions) const response = await axios.get(url, { httpsAgent: agent, From 3af533f84a1fa18b641be22bb83dddef0ce2e828 Mon Sep 17 00:00:00 2001 From: HaykK-Solicy Date: Tue, 2 Jun 2026 15:05:57 +0400 Subject: [PATCH 4/4] fixed error from fetchDomainProof --- src/libs/abstraction/web2/domain.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libs/abstraction/web2/domain.ts b/src/libs/abstraction/web2/domain.ts index 39fd3f58a..053edcab6 100644 --- a/src/libs/abstraction/web2/domain.ts +++ b/src/libs/abstraction/web2/domain.ts @@ -153,12 +153,13 @@ export class DomainProofParser extends Web2ProofParser { } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) + // Full detail (resolved IP / range from the SSRF guard) stays in the + // server log; the thrown message is static so verifyWeb2Proof cannot + // leak internal network info back to the caller. log.error( `[DOMAIN] Failed to fetch proof for ${proofUrl}: ${errorMsg}`, ) - throw new Error( - `Failed to read domain proof at ${proofUrl}: ${errorMsg}`, - ) + throw new Error("Failed to fetch domain proof") } const payload = this.parsePayload(body)