diff --git a/misc/cacao/src/utils.ts b/misc/cacao/src/utils.ts index cd4106cd..a8213cd1 100644 --- a/misc/cacao/src/utils.ts +++ b/misc/cacao/src/utils.ts @@ -47,6 +47,27 @@ export const formatMessage = (cacao: CacaoPayload, iss: string) => { ? `Resources:\n${cacao.resources.map((resource) => `- ${resource}`).join("\n")}` : undefined; + // 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. + 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 = [ header, walletAddress, diff --git a/misc/cacao/test/formatMessage.test.ts b/misc/cacao/test/formatMessage.test.ts new file mode 100644 index 00000000..6bd15fb5 --- /dev/null +++ b/misc/cacao/test/formatMessage.test.ts @@ -0,0 +1,57 @@ +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"); + }); + + 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", + ); + }); +});