Skip to content
Merged
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
144 changes: 144 additions & 0 deletions packages/app/src/connect/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { it, expect, describe } from "vitest";
import {
parseSignRequest,
isRecognizedConnectChallenge,
buildCallbackUrl,
buildSignResult,
encodeSignResult,
encodeReqParam,
extractChallengeNonce,
CONNECT_PROTOCOL,
CONNECT_VERSION,
type SignRequest,
Expand Down Expand Up @@ -152,6 +154,148 @@ describe("parseSignRequest — base64url envelope + round-trip", () => {
});
});

describe("parseSignRequest — callback origin-binding", () => {
const withCallback = (fields: Record<string, unknown>) =>
parseSignRequest(JSON.stringify({ challenge: CHALLENGE, ...fields }));

it("keeps a callback whose origin matches the envelope origin", () => {
const r = withCallback({
origin: "https://surf.rxd.zone",
callback: "https://surf.rxd.zone/auth/photonic-callback",
});
expect(r.ok && r.request.callback).toBe(
"https://surf.rxd.zone/auth/photonic-callback"
);
});

it("keeps a matching callback when the origin is given as a bare host", () => {
const r = withCallback({
origin: "surf.rxd.zone",
callback: "https://surf.rxd.zone/cb",
});
expect(r.ok && r.request.callback).toBe("https://surf.rxd.zone/cb");
});

it("drops a callback pointing at a different origin", () => {
// The attack this binding exists for: site A routing site B's signature
// to an attacker-controlled callback.
const r = withCallback({
origin: "https://surf.rxd.zone",
callback: "https://evil.example/steal",
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.request.callback).toBeUndefined();
});

it("drops a callback that differs only by host suffix, scheme, or port", () => {
const cases = [
"https://surf.rxd.zone.evil.example/cb",
"https://notsurf.rxd.zone/cb",
"http://surf.rxd.zone/cb",
"https://surf.rxd.zone:8443/cb",
];
for (const callback of cases) {
const r = withCallback({ origin: "https://surf.rxd.zone", callback });
expect(r.ok && r.request.callback, callback).toBeUndefined();
}
});

it("drops a callback when the envelope declares no origin to bind to", () => {
const r = withCallback({ callback: "https://surf.rxd.zone/cb" });
expect(r.ok).toBe(true);
if (r.ok) expect(r.request.callback).toBeUndefined();
});

it("drops non-http(s), relative, and credentialed callbacks", () => {
const cases = [
// The literal IS the test: this is the scheme the parser must refuse.
// eslint-disable-next-line no-script-url
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"/auth/photonic-callback",
"https://user:pass@surf.rxd.zone/cb",
];
for (const callback of cases) {
const r = withCallback({ origin: "https://surf.rxd.zone", callback });
expect(r.ok && r.request.callback, callback).toBeUndefined();
}
});

it("strips any fragment the callback arrives with — we own the fragment", () => {
const r = withCallback({
origin: "https://surf.rxd.zone",
callback: "https://surf.rxd.zone/cb#already-here",
});
expect(r.ok && r.request.callback).toBe("https://surf.rxd.zone/cb");
});

it("keeps the request when the callback is malformed", () => {
const r = withCallback({
origin: "https://surf.rxd.zone",
callback: "not a url",
});
expect(r.ok && r.request.challenge).toBe(CHALLENGE);
expect(r.ok && r.request.callback).toBeUndefined();
});
});

describe("extractChallengeNonce", () => {
it("takes the segment after the wallet-connect version", () => {
expect(
extractChallengeNonce("radiant:wallet-connect:v1:abc123:SURF.RXD sign-in")
).toBe("abc123");
});

it("returns undefined for an unrecognized challenge", () => {
expect(extractChallengeNonce("just some text")).toBeUndefined();
expect(extractChallengeNonce("")).toBeUndefined();
});
});

describe("buildCallbackUrl", () => {
const SIGNED = {
address: "14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i",
signature:
"IHdStUu1KegHDyNSnHtD+yRS+A3/0P4xGlyu8yF/HLg9Tjek8tliTbCjbqy1Xi4cMwJuVHQbMBGo5fsPpmZ3W6s=",
};

it("matches the contract's test vector", () => {
const url = buildCallbackUrl(
{
challenge: "radiant:wallet-connect:v1:abc123:SURF.RXD sign-in | …",
callback: "https://surf.rxd.zone/auth/photonic-callback",
},
SIGNED
);
expect(url).toBe(
"https://surf.rxd.zone/auth/photonic-callback#nonce=abc123&address=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i&signature=IHdStUu1KegHDyNSnHtD%2ByRS%2BA3%2F0P4xGlyu8yF%2FHLg9Tjek8tliTbCjbqy1Xi4cMwJuVHQbMBGo5fsPpmZ3W6s%3D"
);
});

it("puts the result in the fragment, never the query", () => {
const url = buildCallbackUrl(
{ challenge: CHALLENGE, callback: "https://surf.rxd.zone/cb" },
SIGNED
)!;
expect(url.indexOf("#")).toBeGreaterThan(-1);
expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/);
expect(url.split("#")[1]).toContain("signature=");
});

it("omits the nonce when the challenge carries none", () => {
const url = buildCallbackUrl(
{ challenge: "freeform text", callback: "https://surf.rxd.zone/cb" },
SIGNED
);
expect(url).not.toContain("nonce=");
expect(url).toContain("address=");
});

it("returns undefined when the request has no callback", () => {
expect(buildCallbackUrl({ challenge: CHALLENGE }, SIGNED)).toBeUndefined();
});
});

describe("isRecognizedConnectChallenge", () => {
it("matches the namespaced wallet-connect shape", () => {
expect(isRecognizedConnectChallenge(CHALLENGE)).toBe(true);
Expand Down
131 changes: 130 additions & 1 deletion packages/app/src/connect/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
* approval, signs via `@lib/sign`, and returns a {@link SignResult}.
* 3. The dApp verifies the signature with radiantjs `Message.verify`.
*
* The result normally returns to the dApp by hand (copy/paste or QR). A request
* may instead opt in to an automatic return by carrying a `callback` URL; the
* result then rides back in that URL's fragment (see {@link buildCallbackUrl}).
* A `callback` is honoured ONLY when its origin matches the envelope's declared
* `origin`, so one site can never route another site's signature elsewhere.
*
* SECURITY: parsing NEVER trusts unvalidated fields. The challenge is run
* through the same guards the signer enforces (`@lib/sign`: length cap +
* no control characters) so the UI can render it verbatim and the service can
Expand All @@ -40,6 +46,13 @@ export type SignRequest = {
app?: string;
/** Address the requester expects to sign; page warns on mismatch (optional). */
address?: string;
/**
* Where to return the signed result, as a URL fragment (optional).
*
* Only ever populated when its origin matches {@link SignRequest.origin} —
* see `cleanCallback`. Absent means the classic manual copy/paste return.
*/
callback?: string;
};

export type SignResult = {
Expand All @@ -60,12 +73,19 @@ const MAX_ID_LEN = 128;
const MAX_LABEL_LEN = 128;
const MAX_ORIGIN_LEN = 256;
const MAX_ADDRESS_LEN = 128;
const MAX_CALLBACK_LEN = 512;

// `<namespace>:wallet-connect:v<n>:...` — the shape Phase A challenges take.
// Used only to badge a request as "recognized" in the UI; non-matching
// challenges are still signable (with a warning), never auto-rejected.
const CONNECT_CHALLENGE_RE = /^[a-z0-9.-]+:wallet-connect:v\d+:/i;

// Captures the segment straight after `…:wallet-connect:v<n>:` — the nonce, in
// the shape the callback contract specifies
// (`radiant:wallet-connect:v1:<nonce>:<label>`). Only used to echo a
// correlation value back to a callback; never load-bearing for the signature.
const CONNECT_NONCE_RE = /^[a-z0-9.-]+:wallet-connect:v\d+:([^:]+)/i;

/** A trimmed, control-char-free, length-bounded display/identifier string. */
function cleanString(v: unknown, maxLen: number): string | undefined {
if (typeof v !== "string") return undefined;
Expand All @@ -88,6 +108,82 @@ function cleanAddress(v: unknown): string | undefined {
return s;
}

/**
* Parse an origin-ish string to its canonical `scheme://host[:port]` form.
* Accepts a full origin (`https://surf.rxd.zone`) or a bare host
* (`surf.rxd.zone`, assumed https). Returns undefined for anything that is not
* an http(s) origin — including `javascript:`/`data:` URLs, whose `.origin` is
* "null" and which must never round-trip a signature.
*/
function toHttpOrigin(v: string): string | undefined {
let url: URL | undefined;
for (const candidate of [v, `https://${v}`]) {
try {
url = new URL(candidate);
break;
} catch {
/* try the next form */
}
}
if (!url) return undefined;
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
if (!url.hostname) return undefined;
return url.origin;
}

/**
* Validate an opt-in result callback, BOUND TO THE ENVELOPE'S ORIGIN.
*
* This binding is the check that matters: without it, site A could hand
* Photonic site B's challenge with an attacker-controlled `callback` and have
* the user's signature delivered to the attacker. A callback is therefore kept
* only when the envelope declares an origin AND the callback resolves to that
* exact origin (scheme, host, and port all). No origin ⇒ nothing to bind to ⇒
* no callback, and the user falls back to the manual copy/paste return.
*
* Any fragment on the callback is dropped — we own the fragment, it is where
* the result rides back.
*/
function cleanCallback(
v: unknown,
origin: string | undefined
): string | undefined {
if (!origin) return undefined;
const s = cleanString(v, MAX_CALLBACK_LEN);
if (!s || /\s/.test(s)) return undefined;

const expected = toHttpOrigin(origin);
if (!expected) return undefined;

let url: URL;
try {
url = new URL(s); // absolute only — a relative callback has no origin to bind
} catch {
return undefined;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
// Embedded credentials would render as part of the URL we navigate to; a
// legitimate callback never needs them.
if (url.username || url.password) return undefined;
if (url.origin !== expected) return undefined;

url.hash = "";
return url.toString();
}

/**
* The nonce inside a recognized connect challenge, if it has one.
*
* Per the callback contract the nonce is the segment right after
* `<ns>:wallet-connect:v<n>:`, letting the requesting site match the response
* to its pending request. Challenges that don't match the recognized shape
* yield undefined and the callback simply carries no `nonce`.
*/
export function extractChallengeNonce(challenge: string): string | undefined {
if (typeof challenge !== "string") return undefined;
return CONNECT_NONCE_RE.exec(challenge)?.[1];
}

/**
* Validate a candidate challenge with the SAME rules the signer enforces.
* Returns an error message, or null if the challenge is safe to sign+display.
Expand Down Expand Up @@ -150,6 +246,7 @@ function normalizeEnvelope(obj: Record<string, unknown>): ParsedRequest {
}
const err = challengeError(obj.challenge);
if (err) return { ok: false, error: err };
const origin = cleanOrigin(obj.origin);
return {
ok: true,
request: {
Expand All @@ -158,9 +255,10 @@ function normalizeEnvelope(obj: Record<string, unknown>): ParsedRequest {
t: "sign-request",
challenge: obj.challenge as string,
id: cleanString(obj.id, MAX_ID_LEN),
origin: cleanOrigin(obj.origin),
origin,
app: cleanString(obj.app, MAX_LABEL_LEN),
address: cleanAddress(obj.address),
callback: cleanCallback(obj.callback, origin),
},
};
}
Expand Down Expand Up @@ -217,6 +315,37 @@ export function buildSignResult(
};
}

/**
* The URL to hand a signed result back to an opt-in `callback`, or undefined
* when the request declared none (the manual copy/paste return).
*
* The result rides in the FRAGMENT, never the query: a fragment is not sent to
* any server, so the signature stays out of access logs, proxy logs, and the
* `Referer` header — it is read client-side by the requesting page. Each value
* is `encodeURIComponent`-escaped because a base64 signature contains `+`, `/`
* and `=`.
*
* The signature's exposure is bounded regardless: it is over a single-use,
* server-issued nonce, so a leaked one cannot be replayed against a different
* challenge.
*/
export function buildCallbackUrl(
req: Pick<SignRequest, "callback" | "challenge">,
result: Pick<SignResult, "address" | "signature">
): string | undefined {
if (!req.callback) return undefined;
const nonce = extractChallengeNonce(req.challenge);
const params: [string, string][] = [
...(nonce ? ([["nonce", nonce]] as [string, string][]) : []),
["address", result.address],
["signature", result.signature],
];
const fragment = params
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&");
return `${req.callback}#${fragment}`;
}

/** Serialize a result for the response QR / copy box. */
export function encodeSignResult(result: SignResult): string {
return JSON.stringify(result);
Expand Down
Loading
Loading