From 17c15bf1ac38cb0a3f1e26d371e0b2d3b0e76996 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Mon, 15 Jun 2026 12:48:38 +0200 Subject: [PATCH] fix(security): remove ReDoS-prone regex in extractCidPath (CodeQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractCidPath ran `s.match(/\/ipfs\/(.+)$/i)` on caller-supplied strings (anchor URLs, CIDs). As a search-anywhere pattern it retries `/ipfs/` at many start positions with a backtracking `.+$`, which CodeQL flags as polynomial ReDoS on hostile inputs like "/ipfs/a/ipfs/a/ipfs/a…". Replace it with a linear `indexOf("/ipfs/")` + `slice`, preserving the exact behavior (everything after the first "/ipfs/" segment that has content, case-insensitive). Add an extractCidPath test covering the equivalence cases plus a hostile-input case that must terminate quickly. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/ipfs.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ src/lib/ipfs.ts | 10 ++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/ipfs.test.ts diff --git a/src/__tests__/ipfs.test.ts b/src/__tests__/ipfs.test.ts new file mode 100644 index 0000000..710bb5f --- /dev/null +++ b/src/__tests__/ipfs.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +// extractCidPath is pure, but the module imports the validated env object. +jest.mock("@/env", () => ({ env: {} })); + +import { extractCidPath } from "@/lib/ipfs"; + +const CID = "QmTzQ1Nj5wW3sM1f8Z9d4VqLp2rXh7Yk6BcDeFgHiJkLm"; + +describe("extractCidPath", () => { + it("returns a bare CID unchanged", () => { + expect(extractCidPath(CID)).toBe(CID); + }); + + it("strips the ipfs:// scheme (with or without an ipfs/ prefix)", () => { + expect(extractCidPath(`ipfs://${CID}`)).toBe(CID); + expect(extractCidPath(`ipfs://ipfs/${CID}`)).toBe(CID); + }); + + it("extracts cid[/path] after /ipfs/ in a gateway URL", () => { + expect(extractCidPath(`https://gateway.pinata.cloud/ipfs/${CID}`)).toBe(CID); + expect(extractCidPath(`https://x.mypinata.cloud/ipfs/${CID}/meta.json`)).toBe( + `${CID}/meta.json`, + ); + }); + + it("matches the /ipfs/ marker case-insensitively", () => { + expect(extractCidPath(`https://x/IPFS/${CID}`)).toBe(CID); + }); + + it("drops query/hash and leading slashes", () => { + expect(extractCidPath(`https://x/ipfs/${CID}?foo=1#bar`)).toBe(CID); + expect(extractCidPath(`/ipfs/${CID}`)).toBe(CID); + }); + + it("returns null for empty, non-ipfs, too-short, or traversal inputs", () => { + expect(extractCidPath("")).toBeNull(); + expect(extractCidPath(null)).toBeNull(); + expect(extractCidPath(undefined)).toBeNull(); + expect(extractCidPath("https://example.com/foo")).toBeNull(); + expect(extractCidPath("short")).toBeNull(); + expect(extractCidPath(`https://x/ipfs/${CID}/../secret`)).toBeNull(); + }); + + it("terminates quickly on hostile repeated /ipfs/ input (no ReDoS)", () => { + const hostile = "/ipfs/a".repeat(20000); + const start = Date.now(); + const result = extractCidPath(hostile); + expect(Date.now() - start).toBeLessThan(1000); + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/ipfs.ts b/src/lib/ipfs.ts index 9f45b63..9032377 100644 --- a/src/lib/ipfs.ts +++ b/src/lib/ipfs.ts @@ -27,8 +27,14 @@ export function extractCidPath(input: string | undefined | null): string | null let s = input.trim(); if (!s) return null; if (s.startsWith("ipfs://")) s = s.replace(/^ipfs:\/\/(ipfs\/)?/i, ""); - const m = s.match(/\/ipfs\/(.+)$/i); - if (m?.[1]) s = m[1]; + // Take everything after the first "/ipfs/" segment, if present. A linear + // indexOf (not a backtracking regex) so hostile inputs like + // "/ipfs/a/ipfs/a/ipfs/a…" can't trigger polynomial ReDoS (CodeQL). + const marker = "/ipfs/"; + const ipfsIdx = s.toLowerCase().indexOf(marker); + if (ipfsIdx !== -1 && ipfsIdx + marker.length < s.length) { + s = s.slice(ipfsIdx + marker.length); + } s = s.split(/[?#]/)[0]!.replace(/^\/+/, ""); if (!s || s.includes("..")) return null; const cid = s.split("/")[0]!;