diff --git a/.github/workflows/feature-to-dev-pr.yml b/.github/workflows/feature-to-dev-pr.yml index 8a61dfc4..1d2fe452 100644 --- a/.github/workflows/feature-to-dev-pr.yml +++ b/.github/workflows/feature-to-dev-pr.yml @@ -6,6 +6,10 @@ on: - main - dev - "dependabot/**" + # Cloud-agent branches already open a PR into main as the human owner. + # Auto-opening a second PR into dev (authored by github-actions, titled + # with the cursor/ prefix) duplicates review and is not wanted. + - "cursor/**" jobs: create-pull-request: diff --git a/package.json b/package.json index bdcf132d..0954cf6d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "pnpm": { "overrides": { - "postcss": "^8.5.18", + "postcss": "^8.5.23", "esbuild": "^0.25.12", "ws": "^8.20.1", "@eslint/plugin-kit": "^0.3.4", @@ -36,7 +36,7 @@ "undici": "^6.27.0", "sharp": "^0.35.0", "vite": "^7.3.5", - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.9" } } } diff --git a/packages/api/package.json b/packages/api/package.json index ff66d3c3..da963a79 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,8 +29,7 @@ "@trpc/react-query": "11.18.0", "@trpc/server": "11.18.0", "drizzle-orm": "0.45.2", - "image-size": "2.0.2", - "sanitize-html": "2.17.4", + "sanitize-html": "2.17.5", "stripe": "^22.0.0", "superjson": "2.2.3", "zod": "3.25.53" diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index bd18bff7..55f61624 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { CacheKeys } from "../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { fetchPortalContext } from "../services/portal-context"; +import { readImageDimensions } from "../services/image-dimensions"; // z.string().url() is backed by new URL(), which accepts any scheme — a stored // data: or javascript: URI is handed straight back to whoever renders it. @@ -185,9 +186,8 @@ export const userRouter = createTRPCRouter({ const buffer = Buffer.from(base64Data, "base64"); try { - const { imageSize } = await import("image-size"); - const dimensions = imageSize(buffer); - if (!dimensions.width || !dimensions.height) { + const dimensions = readImageDimensions(buffer); + if (!dimensions?.width || !dimensions.height) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid image dimensions. File may be corrupt.", @@ -200,8 +200,8 @@ export const userRouter = createTRPCRouter({ "Image dimensions exceed the maximum allowed size of 2000x2000 pixels.", }); } - const allowedTypes = ["jpg", "jpeg", "png", "webp"]; - if (!dimensions.type || !allowedTypes.includes(dimensions.type)) { + const allowedTypes = ["jpeg", "png", "webp"]; + if (!allowedTypes.includes(dimensions.type)) { throw new TRPCError({ code: "BAD_REQUEST", message: diff --git a/packages/api/src/services/image-dimensions.test.ts b/packages/api/src/services/image-dimensions.test.ts new file mode 100644 index 00000000..72333bb6 --- /dev/null +++ b/packages/api/src/services/image-dimensions.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { readImageDimensions } from "./image-dimensions"; + +const png = (width: number, height: number) => { + const buf = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf); + buf.writeUInt32BE(13, 8); + buf.write("IHDR", 12); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +}; + +const jpegSof = (width: number, height: number) => { + // SOI (2) + SOF0 marker (2) + length-inclusive segment (11) + const buf = Buffer.alloc(15); + buf[0] = 0xff; + buf[1] = 0xd8; + buf[2] = 0xff; + buf[3] = 0xc0; + buf.writeUInt16BE(11, 4); + buf[6] = 8; + buf.writeUInt16BE(height, 7); + buf.writeUInt16BE(width, 9); + buf[11] = 1; + return buf; +}; + +const webpVp8x = (width: number, height: number) => { + const buf = Buffer.alloc(30); + buf.write("RIFF", 0); + buf.writeUInt32LE(22, 4); + buf.write("WEBP", 8); + buf.write("VP8X", 12); + buf.writeUInt32LE(10, 16); + const w = width - 1; + const h = height - 1; + buf[24] = w & 0xff; + buf[25] = (w >> 8) & 0xff; + buf[26] = (w >> 16) & 0xff; + buf[27] = h & 0xff; + buf[28] = (h >> 8) & 0xff; + buf[29] = (h >> 16) & 0xff; + return buf; +}; + +describe("readImageDimensions", () => { + it("reads PNG IHDR width and height", () => { + expect(readImageDimensions(png(640, 480))).toEqual({ + width: 640, + height: 480, + type: "png", + }); + }); + + it("reads JPEG SOF0 width and height", () => { + expect(readImageDimensions(jpegSof(32, 16))).toEqual({ + width: 32, + height: 16, + type: "jpeg", + }); + }); + + it("reads WebP VP8X canvas size", () => { + expect(readImageDimensions(webpVp8x(200, 100))).toEqual({ + width: 200, + height: 100, + type: "webp", + }); + }); + + it("refuses zero-sized and truncated buffers instead of looping", () => { + expect(readImageDimensions(png(0, 10))).toBeNull(); + expect(readImageDimensions(Buffer.from("icns"))).toBeNull(); + expect(readImageDimensions(Buffer.alloc(0))).toBeNull(); + // A zero-size JXL/HEIF box used to hang image-size. We never parse those. + const jxlish = Buffer.alloc(32, 0); + jxlish.write("JXL ", 4); + expect(readImageDimensions(jxlish)).toBeNull(); + }); +}); diff --git a/packages/api/src/services/image-dimensions.ts b/packages/api/src/services/image-dimensions.ts new file mode 100644 index 00000000..18699e81 --- /dev/null +++ b/packages/api/src/services/image-dimensions.ts @@ -0,0 +1,140 @@ +/** + * Dimensions for the three types the profile-image upload already allows. + * + * `image-size` through 2.0.2 (the latest published release) infinite-loops on + * crafted ICNS / JXL / HEIF buffers. There is no patched version on npm, so + * this parser understands only PNG, JPEG and WebP — the same types the data-URI + * regex already admits. Anything else is corrupt, not "try the next format". + */ + +export type ImageKind = "png" | "jpeg" | "webp"; + +export type ImageDimensions = { + width: number; + height: number; + type: ImageKind; +}; + +const PNG_SIG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const isFinitePositive = (n: number) => + Number.isInteger(n) && n > 0 && n <= 0xffff_ffff; + +const pngDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 24) return null; + if (!buf.subarray(0, 8).equals(PNG_SIG)) return null; + if (buf.toString("ascii", 12, 16) !== "IHDR") return null; + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "png" }; +}; + +const jpegDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + + let offset = 2; + while (offset + 3 < buf.length) { + if (buf[offset] !== 0xff) return null; + while (offset < buf.length && buf[offset] === 0xff) offset += 1; + if (offset >= buf.length) return null; + + const marker = buf[offset]!; + offset += 1; + + // Standalone markers (no length): RST0–RST7, SOI, EOI, TEM. + if ( + marker === 0xd8 || + marker === 0xd9 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { + if (marker === 0xd9) return null; + continue; + } + + if (offset + 1 >= buf.length) return null; + const length = buf.readUInt16BE(offset); + if (length < 2 || offset + length > buf.length) return null; + + // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 carry the frame size. + const isSof = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + + if (isSof) { + if (length < 7 || offset + 6 >= buf.length) return null; + const height = buf.readUInt16BE(offset + 3); + const width = buf.readUInt16BE(offset + 5); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "jpeg" }; + } + + offset += length; + } + + return null; +}; + +const readUInt24LE = (buf: Buffer, offset: number) => + buf[offset]! | (buf[offset + 1]! << 8) | (buf[offset + 2]! << 16); + +const webpDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 16) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF") return null; + if (buf.toString("ascii", 8, 12) !== "WEBP") return null; + + const fourcc = buf.toString("ascii", 12, 16); + if (buf.length < 20) return null; + const chunkSize = buf.readUInt32LE(16); + const payload = 20; + + if (fourcc === "VP8X") { + // 1 byte flags + 3 reserved + 3 width-1 + 3 height-1 + if (chunkSize < 10 || buf.length < payload + 10) return null; + const width = readUInt24LE(buf, payload + 4) + 1; + const height = readUInt24LE(buf, payload + 7) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8L") { + // signature 0x2f, then 14-bit width-1 and 14-bit height-1. + if (chunkSize < 5 || buf.length < payload + 5) return null; + if (buf[payload] !== 0x2f) return null; + const bits = + buf[payload + 1]! | + (buf[payload + 2]! << 8) | + (buf[payload + 3]! << 16) | + (buf[payload + 4]! << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8 ") { + // 3-byte frame tag, then 0x9d 0x01 0x2a, then 16-bit width/height (14 used). + if (chunkSize < 10 || buf.length < payload + 10) return null; + if ( + buf[payload + 3] !== 0x9d || + buf[payload + 4] !== 0x01 || + buf[payload + 5] !== 0x2a + ) { + return null; + } + const width = buf.readUInt16LE(payload + 6) & 0x3fff; + const height = buf.readUInt16LE(payload + 8) & 0x3fff; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + return null; +}; + +export const readImageDimensions = (buf: Buffer): ImageDimensions | null => + pngDimensions(buf) ?? jpegDimensions(buf) ?? webpDimensions(buf); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index fe930fe4..88037fdb 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -72,16 +72,134 @@ const requiresDb = t.middleware(async ({ ctx, next }) => { * places a value might reach an HTML sink, and a hackathon full of people * writing `vector` or `a]*\bon[a-z]+\s*=/`) were + * polynomial in the length of attacker-controlled input (CodeQL #804/#805): + * nested `\s*` and `[^>]*` plus a later alternative make the matcher walk the + * same prefix over and over. A hackathon payload is large enough for that to + * stall the instance; a linear walk cannot. + */ +const DANGEROUS_TAGS = [ + "script", + "iframe", + "object", + "embed", + "link", + "meta", + "base", + "svg", + "math", + "style", + "form", + "input", + "button", + "img", + "video", + "audio", + "source", + "track", + "template", + "noscript", + "textarea", + "xmp", + "frame", + "frameset", + "applet", +] as const; + +const isHtmlSpace = (ch: string) => + ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f"; + +const isAsciiLetter = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z"); + +const isNameBoundary = (ch: string | undefined) => { + if (ch === undefined) return true; + const c = ch.toLowerCase(); + return !( + (c >= "a" && c <= "z") || + (c >= "0" && c <= "9") || + c === "-" + ); +}; + +/** + * `onerror=` / `onload=` only count inside a tag. Matched loosely it would + * reject prose like "onboarding = great". + * + * `end` is already bounded (next `>` or 2048 chars), so this is linear in a + * small window rather than in the whole payload. + */ +const hasInlineHandler = (lower: string, start: number, end: number) => { + let pos = start; + while (pos < end) { + const on = lower.indexOf("on", pos); + if (on === -1 || on >= end) return false; + if (on > start) { + const prev = lower[on - 1]!; + if (!isHtmlSpace(prev) && prev !== "<") { + pos = on + 1; + continue; + } + } + let k = on + 2; + let n = 0; + while (k < end && n < 32) { + const ch = lower[k]!; + if (ch < "a" || ch > "z") break; + k += 1; + n += 1; + } + if (n === 0) { + pos = on + 1; + continue; + } + while (k < end && isHtmlSpace(lower[k]!)) k += 1; + if (k < end && lower[k] === "=") return true; + pos = on + 1; + } + return false; +}; -// An event handler only means anything inside a tag; matched loosely it would -// reject prose like "onboarding = great". -const TAG_WITH_HANDLER = /<[a-zA-Z][^>]*\bon[a-z]+\s*=/i; +/** + * True when the string could execute if it reached an HTML sink. + * + * `javascript:` is a substring check (case-insensitive). Tags and handlers + * are found by walking `<` … `>` so combining characters / long runs of + * spaces cannot force backtracking. + */ +export const hasDangerousMarkup = (value: string): boolean => { + const lower = value.toLowerCase(); + if (lower.includes("javascript:")) return true; + + for (let i = 0; i < lower.length; i += 1) { + if (lower[i] !== "<") continue; + + let j = i + 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + if (j < lower.length && lower[j] === "/") { + j += 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + } + for (const tag of DANGEROUS_TAGS) { + if (lower.startsWith(tag, j) && isNameBoundary(lower[j + tag.length])) { + return true; + } + } + + // Original handler regex required a letter immediately after `<`. + if (i + 1 < lower.length && isAsciiLetter(lower[i + 1]!)) { + const gt = lower.indexOf(">", i + 1); + const end = gt === -1 ? Math.min(lower.length, i + 2048) : gt; + if (hasInlineHandler(lower, i, end)) return true; + } + } -// Still dangerous as plain text: whoever renders it into an href gets an -// executable link. -const SCRIPTABLE_URI = /javascript:/i; + return false; +}; const isPlainObject = (value: object) => { const proto = Object.getPrototypeOf(value) as object | null; @@ -129,11 +247,7 @@ export const scrubMarkup = (input: unknown, depth = 0): unknown => { } if (typeof input === "string") { - if ( - DANGEROUS_TAG.test(input) || - TAG_WITH_HANDLER.test(input) || - SCRIPTABLE_URI.test(input) - ) { + if (hasDangerousMarkup(input)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input: HTML and script content are not allowed", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcee082..6b1e7f67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - postcss: ^8.5.18 + postcss: ^8.5.23 esbuild: ^0.25.12 ws: ^8.20.1 '@eslint/plugin-kit': ^0.3.4 @@ -17,7 +17,7 @@ overrides: undici: ^6.27.0 sharp: ^0.35.0 vite: ^7.3.5 - brace-expansion: ^5.0.8 + brace-expansion: ^5.0.9 importers: @@ -63,12 +63,9 @@ importers: drizzle-orm: specifier: 0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) - image-size: - specifier: 2.0.2 - version: 2.0.2 sanitize-html: - specifier: 2.17.4 - version: 2.17.4 + specifier: 2.17.5 + version: 2.17.5 stripe: specifier: ^22.0.0 version: 22.1.1(@types/node@22.15.32) @@ -261,7 +258,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -357,8 +354,8 @@ importers: specifier: 1.9.3 version: 1.9.3(react-dom@19.0.0(react@19.2.7))(react@19.2.7) sanitize-html: - specifier: ^2.17.4 - version: 2.17.4 + specifier: ^2.17.5 + version: 2.17.5 stripe: specifier: ^22.0.0 version: 22.1.1(@types/node@22.15.32) @@ -418,7 +415,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -528,7 +525,7 @@ importers: specifier: 16.3.0 version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 react: specifier: 19.2.7 @@ -2251,7 +2248,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.18 + postcss: ^8.5.23 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2284,8 +2281,8 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2941,11 +2938,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3721,8 +3713,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sanitize-html@2.17.4: - resolution: {integrity: sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==} + sanitize-html@2.17.5: + resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==} scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -4166,7 +4158,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -5800,7 +5792,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -6494,8 +6486,6 @@ snapshots: ignore@7.0.5: {} - image-size@2.0.2: {} - imurmurhash@0.1.4: {} internal-slot@1.1.0: @@ -6759,15 +6749,15 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@10.2.4: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -7218,7 +7208,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sanitize-html@2.17.4: + sanitize-html@2.17.5: dependencies: deepmerge: 4.3.1 escape-string-regexp: 4.0.0 diff --git a/sites/hacklytics2027/package.json b/sites/hacklytics2027/package.json index 56a3d993..247f4576 100644 --- a/sites/hacklytics2027/package.json +++ b/sites/hacklytics2027/package.json @@ -27,7 +27,7 @@ "autoprefixer": "10.4.22", "baseline-browser-mapping": "2.9.19", "eslint": "10.1.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "tailwindcss": "4.3.0", "typescript": "5.8.3", "typescript-eslint": "^8.59.2" diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index e66f9b7a..79f588c0 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -27,8 +27,14 @@ import { const safeLogId = (value: unknown) => String(value ?? "") .replace(/[^\w-]/g, "") + .replace(/[\n\r]/g, "") .slice(0, 64); +const safeLogError = (err: unknown) => + err instanceof Error + ? err.name.replace(/[\n\r]/g, "") + : "Error"; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const stripe = process.env.STRIPE_SECRET_KEY @@ -173,7 +179,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] payment marked paid, membership grant failed", safeLogId(existingPayment.id), - e, + safeLogError(e), ); } } @@ -256,7 +262,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] membership grant failed for checkout session", safeLogId(session.id), - e, + safeLogError(e), ); } } @@ -373,7 +379,7 @@ export async function POST(req: NextRequest) { console.error( "[Stripe webhook] membership grant failed for payment intent", safeLogId(pi.id), - e, + safeLogError(e), ); } } diff --git a/sites/mainweb/app/api/csp-report/route.ts b/sites/mainweb/app/api/csp-report/route.ts index f64a612a..2c42f386 100644 --- a/sites/mainweb/app/api/csp-report/route.ts +++ b/sites/mainweb/app/api/csp-report/route.ts @@ -45,10 +45,13 @@ export async function POST(request: NextRequest) { } // Two formats in the wild: the legacy `report-uri` shape - // ({"csp-report": {...}}) and the newer Reporting API array. Log whichever - // arrives rather than parsing both into one shape — this is a diagnostic, - // not a data pipeline. - console.warn("[CSP] violation report:", body.slice(0, MAX_REPORT_BYTES)); + // ({"csp-report": {...}}) and the newer Reporting API array. Log a + // newline-stripped copy — the body is attacker-controlled (this endpoint + // is unauthenticated) and a raw CR/LF would forge extra log lines. + const forLog = body + .slice(0, MAX_REPORT_BYTES) + .replace(/[\n\r]/g, " "); + console.warn("[CSP] violation report:", forLog); } catch (error) { console.error("[CSP] failed to read a violation report:", error); } diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json index 3a1bc233..92515417 100644 --- a/sites/mainweb/package.json +++ b/sites/mainweb/package.json @@ -39,7 +39,7 @@ "react-chartjs-2": "5.3.1", "react-dom": "19.0.0", "react-scroll": "1.9.3", - "sanitize-html": "^2.17.4", + "sanitize-html": "^2.17.5", "stripe": "^22.0.0", "superjson": "^2.2.3" }, @@ -61,7 +61,7 @@ "autoprefixer": "^10.4.22", "cross-env": "^7.0.3", "eslint": "10.1.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "tailwindcss": "4.3.0", "turbo": "^2.9.14", "typescript": "^6.0.2", diff --git a/tooling/tailwind/package.json b/tooling/tailwind/package.json index df425c73..adee341d 100644 --- a/tooling/tailwind/package.json +++ b/tooling/tailwind/package.json @@ -10,7 +10,7 @@ "dependencies": { "@tailwindcss/postcss": "4.0.0", "next": "16.3.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "react": "19.2.7", "react-dom": "19.0.0", "tailwindcss": "4.0.0"