From a8795c1fd2b14a91179c9f58fd9c5ac2593481f2 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sun, 9 Aug 2026 13:00:04 +0700 Subject: [PATCH 1/2] fix(cacao): reject SIWE statement line breaks in formatMessage Parity with @walletconnect/utils: embedded CR/LF in statement can forge later EIP-4361 fields (URI, Nonce) in the reconstructed signed message. --- misc/cacao/src/utils.ts | 7 +++++ misc/cacao/test/formatMessage.test.ts | 39 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 misc/cacao/test/formatMessage.test.ts diff --git a/misc/cacao/src/utils.ts b/misc/cacao/src/utils.ts index cd4106cd..ac72e987 100644 --- a/misc/cacao/src/utils.ts +++ b/misc/cacao/src/utils.ts @@ -47,6 +47,13 @@ export const formatMessage = (cacao: CacaoPayload, iss: string) => { ? `Resources:\n${cacao.resources.map((resource) => `- ${resource}`).join("\n")}` : undefined; + // Per EIP-4361 the statement is a single line. Reject embedded breaks so a + // caller-supplied statement cannot forge later fields (URI, Nonce, etc.). + // Parity with @walletconnect/utils formatMessage in walletconnect-monorepo. + if (statement && /\r|\n/.test(statement)) { + throw new Error("Statement must not contain line breaks (`\\r` or `\\n`)"); + } + const message = [ header, walletAddress, diff --git a/misc/cacao/test/formatMessage.test.ts b/misc/cacao/test/formatMessage.test.ts new file mode 100644 index 00000000..c0555c41 --- /dev/null +++ b/misc/cacao/test/formatMessage.test.ts @@ -0,0 +1,39 @@ +import "mocha"; +import { expect } from "chai"; +import { formatMessage } from "../src/utils"; +import { CacaoPayload } from "../src/types"; + +const iss = "did:pkh:eip155:1:0x2faf83c542b68f1b4cdc0e770e8cb9f567b08f71"; + +const base: CacaoPayload = { + iss, + domain: "example.com", + aud: "https://example.com/login", + version: "1", + nonce: "12345678", + iat: "2024-01-01T00:00:00.000Z", + statement: "I accept the Terms of Service", +}; + +describe("formatMessage", () => { + it("formats a normal statement", () => { + const message = formatMessage(base, iss); + expect(message).to.include("I accept the Terms of Service"); + expect(message).to.include("URI: https://example.com/login"); + }); + + it("rejects statement with embedded newline (field smuggling)", () => { + expect(() => + formatMessage( + { ...base, statement: "I accept\nURI: https://evil.com" }, + iss, + ), + ).to.throw("Statement must not contain line breaks"); + }); + + it("rejects statement with carriage return", () => { + expect(() => + formatMessage({ ...base, statement: "I accept\rURI: https://evil.com" }, iss), + ).to.throw("Statement must not contain line breaks"); + }); +}); From e8b0c384b0635419ba5daebd0f5fe487e2fce23b Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Tue, 11 Aug 2026 17:30:57 +0700 Subject: [PATCH 2/2] fix(cacao): reject CR/LF in all SIWE single-line fields Extend formatMessage beyond statement so domain, aud, version, nonce, iat (and optional exp/nbf/requestId) cannot smuggle EIP-4361 lines. Parity with walletconnect-monorepo formatMessage. --- misc/cacao/src/utils.ts | 22 ++++++++++++++++++---- misc/cacao/test/formatMessage.test.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/misc/cacao/src/utils.ts b/misc/cacao/src/utils.ts index ac72e987..a8213cd1 100644 --- a/misc/cacao/src/utils.ts +++ b/misc/cacao/src/utils.ts @@ -47,11 +47,25 @@ export const formatMessage = (cacao: CacaoPayload, iss: string) => { ? `Resources:\n${cacao.resources.map((resource) => `- ${resource}`).join("\n")}` : undefined; - // Per EIP-4361 the statement is a single line. Reject embedded breaks so a - // caller-supplied statement cannot forge later fields (URI, Nonce, etc.). + // Per EIP-4361 single-line fields must not contain line breaks. Reject caller-supplied + // domain / aud / version / nonce / iat (and optional exp/nbf/requestId) as well as + // statement so embedded `\r`/`\n` cannot forge other fields in the signed message. // Parity with @walletconnect/utils formatMessage in walletconnect-monorepo. - if (statement && /\r|\n/.test(statement)) { - throw new Error("Statement must not contain line breaks (`\\r` or `\\n`)"); + const singleLineFields: Array<[string, string | undefined]> = [ + ["Domain", cacao.domain], + ["URI", cacao.aud], + ["Version", cacao.version], + ["Nonce", cacao.nonce], + ["Issued At", cacao.iat], + ["Expiration Time", cacao.exp], + ["Not Before", cacao.nbf], + ["Request ID", cacao.requestId], + ["Statement", statement], + ]; + for (const [name, value] of singleLineFields) { + if (value && /\r|\n/.test(value)) { + throw new Error(`${name} must not contain line breaks (\`\\r\` or \`\\n\`)`); + } } const message = [ diff --git a/misc/cacao/test/formatMessage.test.ts b/misc/cacao/test/formatMessage.test.ts index c0555c41..6bd15fb5 100644 --- a/misc/cacao/test/formatMessage.test.ts +++ b/misc/cacao/test/formatMessage.test.ts @@ -36,4 +36,22 @@ describe("formatMessage", () => { formatMessage({ ...base, statement: "I accept\rURI: https://evil.com" }, iss), ).to.throw("Statement must not contain line breaks"); }); + + it("rejects domain with embedded newline (field smuggling)", () => { + expect(() => + formatMessage({ ...base, domain: "good.com\nURI: https://evil.com" }, iss), + ).to.throw("Domain must not contain line breaks"); + }); + + it("rejects aud with embedded newline", () => { + expect(() => + formatMessage({ ...base, aud: "https://example.com/login\nURI: https://evil.com" }, iss), + ).to.throw("URI must not contain line breaks"); + }); + + it("rejects nonce with carriage return", () => { + expect(() => formatMessage({ ...base, nonce: "1234\r5678" }, iss)).to.throw( + "Nonce must not contain line breaks", + ); + }); });