Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/libs/abstraction/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -172,7 +173,7 @@
* @param payload - The proof payload
* @returns true if the proof is valid, false otherwise
*/
export async function verifyWeb2Proof(

Check failure on line 176 in src/libs/abstraction/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_node&issues=AZ6DtM-6faF2edLC68Jx&open=AZ6DtM-6faF2edLC68Jx&pullRequest=897
payload: Web2CoreTargetIdentityPayload,
sender: string,
) {
Expand All @@ -180,6 +181,7 @@
| typeof TwitterProofParser
| typeof GithubProofParser
| typeof DiscordProofParser
| typeof DomainProofParser

switch (payload.context) {
case "twitter":
Expand All @@ -194,6 +196,43 @@
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}`,
}
}
// 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",
}
}
Comment thread
HaykK-Solicy marked this conversation as resolved.
parser = DomainProofParser
break
}
default:
return {
success: false,
Expand Down
179 changes: 179 additions & 0 deletions src/libs/abstraction/web2/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import axios from "axios"
import https from "https"

Check warning on line 2 in src/libs/abstraction/web2/domain.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:https` over `https`.

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_node&issues=AZ6DtM_CfaF2edLC68Jy&open=AZ6DtM_CfaF2edLC68Jy&pullRequest=897
import dns from "dns"

Check warning on line 3 in src/libs/abstraction/web2/domain.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:dns` over `dns`.

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_node&issues=AZ6H4xnQNfqVK6sg25dy&open=AZ6H4xnQNfqVK6sg25dy&pullRequest=897
import ipaddr from "ipaddr.js"
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

/** 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 resolveAndValidateHost(
hostname: string,
allowLocal: boolean,
): Promise<{ address: string; family: number }> {
let resolved: { address: string; family: number }[]
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})`,
)
}

// 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]
}

/**
* 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.
*
* 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.
*/
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 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.
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) {

Check warning on line 109 in src/libs/abstraction/web2/domain.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_node&issues=AZ6H91B8-lUjLlBHWeN7&open=AZ6H91B8-lUjLlBHWeN7&pullRequest=897
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,
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) {
const errorMsg =
error instanceof Error ? error.message : String(error)

Check warning on line 155 in src/libs/abstraction/web2/domain.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'error' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_node&issues=AZ6H4xnQNfqVK6sg25dz&open=AZ6H4xnQNfqVK6sg25dz&pullRequest=897
// 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 fetch domain proof")
}
Comment thread
HaykK-Solicy marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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
}
}
3 changes: 3 additions & 0 deletions src/libs/abstraction/web2/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ 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://"],
Comment thread
HaykK-Solicy marked this conversation as resolved.
}

constructor() {}
Expand Down
50 changes: 50 additions & 0 deletions src/libs/network/handlers/identityHandlers.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -159,6 +160,55 @@ export const identityHandlers: Record<string, NodeCallHandler> = {
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.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 = {
success: false,
error: `Proof must be hosted at ${DOMAIN_PROOF_PATH}`,
}
return response
}
Comment thread
HaykK-Solicy marked this conversation as resolved.

try {
const { hostname, body } = await fetchDomainProof(data.url)
Comment thread
HaykK-Solicy marked this conversation as resolved.
response.result = 200
response.response = { success: true, hostname, body }
} catch (error) {
log.error("[getDomainProof] failed to fetch domain proof", error)
response.result = 400
response.response = {
success: false,
error: "Failed to fetch domain proof",
}
}
Comment thread
HaykK-Solicy marked this conversation as resolved.
Comment thread
HaykK-Solicy marked this conversation as resolved.
return response
},
Comment thread
HaykK-Solicy marked this conversation as resolved.

resolveUdDomain: async (data, response) => {
try {
const res = await UDIdentityManager.resolveUDDomain(data.domain)
Expand Down
1 change: 1 addition & 0 deletions src/libs/omniprotocol/protocol/opcodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/libs/omniprotocol/protocol/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ const DESCRIPTORS: HandlerDescriptor[] = [
authRequired: false,
handler: createHttpFallbackHandler(),
},
{
opcode: OmniOpcode.GET_DOMAIN_PROOF,
name: "getDomainProof",
authRequired: false,
handler: createHttpFallbackHandler(),
},

// 0x6X Admin
{
Expand Down
Loading