Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
11 changes: 11 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,15 @@ jobs:

# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
#
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
# on a patched base or bumping the dep. Without it the advisory upload floods the
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
# patch yet, overwhelmingly local-only and not reachable from the proxy request
# surface), which is noise an operator cannot act on. trivyignores points at the
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
# See docs/security/SUPPLY_CHAIN.md.
- name: Trivy image scan (SARIF, advisory)
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
Expand All @@ -353,6 +362,8 @@ jobs:
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore
exit-code: "0"

# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
Expand Down
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## DOCS-XSS-TRAVERSAL - Stored XSS and Path Traversal in Documentation Rendering
**Date:** 2026-06-28
**Vulnerability:** Stored XSS via `dangerouslySetInnerHTML` with unsanitized markdown output and potential Path Traversal in the i18n documentation fallback logic.
**Learning:** Server-side rendering of user-provided (or contributor-provided) markdown in Next.js requires explicit sanitization when using `dangerouslySetInnerHTML`. Standard library `dompurify` requires a DOM environment (provided by `jsdom`) to run on the server. Path traversal checks must combine regex validation of input segments with absolute path resolution (`path.resolve`) and a prefix check (`startsWith`) for robust defense.
**Prevention:** Always sanitize HTML rendered from markdown on the server. Use a centralized sanitizer utility. Validate all file-system related input parameters against a strict allowlist (e.g., `/^[a-z0-9-]+$/i`).
22 changes: 22 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# .trivyignore — accepted-risk suppressions for the container image scan
#
# Policy (see docs/security/SUPPLY_CHAIN.md):
# - The Trivy steps in .github/workflows/docker-publish.yml run with
# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are
# already excluded from both the blocking CRITICAL gate and the advisory
# Security-tab upload. You do NOT need an entry here for an unfixable
# base-image OS CVE — it will not be reported.
# - This file is the single auditable home for the rare case where a *fixable*
# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the
# pinned base tag, or the affected package/binary is provably unreachable
# from the proxy request surface and rebuilding now is not justified).
#
# Format — one CVE id per line, each with a justification comment and, where
# possible, an expiry, e.g.:
# # CVE-XXXX-YYYY — <why accepted>; revisit on next base-image bump (YYYY-MM-DD)
# CVE-XXXX-YYYY
#
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.
#
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@
FROM node:24-trixie-slim AS base
WORKDIR /app

# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
# base-image packages at build time — clears the subset of container-scan CVEs
# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …)
# that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
# ship the patched versions. These are npm's own internals — not application
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
# but the container scanner flags the stale copies under
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
# the runner stages, so this is hygiene, not an exploitable runtime path.
RUN npm install -g npm@latest \
&& npm cache clean --force

# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder

Expand Down
21 changes: 19 additions & 2 deletions src/app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DEFAULT_LOCALE, LOCALE_COOKIE } from "@/i18n/config";
import fs from "node:fs";
import path from "node:path";
import { marked } from "marked";
import { sanitizeDocsHtml } from "@/lib/docsSanitizer";

// ── Locale detection ────────────────────────────────────────────────────────

Expand All @@ -28,8 +29,22 @@ function getDocsLocale(): string {
function tryI18nFallback(slug: string[], locale: string): string | null {
if (!locale || locale === "en") return null;

// 🛡️ Sentinel: Path traversal prevention
// 1. Validate locale and slug characters (only alphanumeric, dashes, underscores)
if (!/^[a-z0-9-]+$/i.test(locale)) return null;
if (!slug.every((s) => /^[a-z0-9-]+$/i.test(s))) return null;

const docsRoot = path.resolve(process.cwd(), "docs");
const sectionDir = path.join(docsRoot, "i18n", locale, "docs", ...slug.slice(0, -1));
const i18nRoot = path.join(docsRoot, "i18n");

// 2. Resolve target directory absolute path
const sectionDir = path.resolve(i18nRoot, locale, "docs", ...slug.slice(0, -1));

// 3. Ensure the resolved path remains within docs/i18n to prevent traversal
if (!sectionDir.startsWith(i18nRoot)) {
return null;
}

if (!fs.existsSync(sectionDir)) return null;

// Fumadocs lowercases slugs — match case-insensitively against i18n dir
Expand All @@ -55,7 +70,9 @@ function tryI18nFallback(slug: string[], locale: string): string | null {
? raw.slice(bodyMatch.index + bodyMatch[0].length).trim()
: raw;

return marked.parse(body) as string;
// 🛡️ Sentinel: XSS protection via server-side sanitization of rendered markdown
const html = marked.parse(body) as string;
return sanitizeDocsHtml(html);
}

// ── Page component ──────────────────────────────────────────────────────────
Expand Down
36 changes: 36 additions & 0 deletions src/lib/docsSanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

let sanitizer: createDOMPurify.DOMPurifyI | null = null;

/**
* Get or create a server-side DOMPurify instance.
*/
function getSanitizer(): createDOMPurify.DOMPurifyI {
if (!sanitizer) {
const window = new JSDOM("").window;
sanitizer = createDOMPurify(window as unknown as Window);
}
return sanitizer;
}

/**
* Sanitize HTML content for documentation display.
* @param html The raw HTML to sanitize
*/
export function sanitizeDocsHtml(html: string): string {
const purify = getSanitizer();
return purify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p", "a", "ul", "ol",
"nl", "li", "ins", "del", "te", "bw", "em", "strong", "span", "hr", "br",
"div", "table", "thead", "caption", "tbody", "tr", "th", "td", "pre",
"code", "img", "details", "summary", "input"
],
ALLOWED_ATTR: [
"href", "name", "target", "src", "alt", "title", "class", "id", "type",
"checked", "disabled", "rel"
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Allowing the <input> tag along with attributes like type, name, checked, and disabled introduces a potential security risk (Stored XSS / Phishing / UI Redressing). An attacker could inject malicious input fields (e.g., text or password inputs) to harvest credentials or mislead users. Additionally, "nl", "te", and "bw" are not standard HTML tags and appear to be typos.

Recommendations:

  1. Remove "input" from ALLOWED_TAGS and "type", "checked", "disabled" from ALLOWED_ATTR if they are not strictly required.
  2. If task lists (checkboxes) are required, use a DOMPurify hook (e.g., uponSanitizeElement) to strictly enforce that any <input> element has type="checkbox" and disabled attributes, stripping any other input types.
  3. Remove the non-standard tags "nl", "te", and "bw".
    ALLOWED_TAGS: [
      "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p", "a", "ul", "ol",
      "li", "ins", "del", "em", "strong", "span", "hr", "br",
      "div", "table", "thead", "caption", "tbody", "tr", "th", "td", "pre",
      "code", "img", "details", "summary"
    ],
    ALLOWED_ATTR: [
      "href", "name", "target", "src", "alt", "title", "class", "id", "rel"
    ],

});
}
38 changes: 38 additions & 0 deletions tests/unit/security/docs-path-traversal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";

// We can't easily unit test tryI18nFallback because it's internal to the page component
// and depends on process.cwd() and fs. But we can test the regex logic.

const LOCALE_REGEX = /^[a-z0-9-]+$/i;
const SLUG_REGEX = /^[a-z0-9-]+$/i;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues with this implementation:

  1. Testing Local Definitions: The test defines LOCALE_REGEX and SLUG_REGEX locally within the test file. This means the test is only validating its own local regexes rather than the actual regexes used in the production code (e.g., in the page component), which defeats the purpose of unit testing.
  2. Style Guide Violation: According to the Repository Style Guide (Rule 8), all inputs must be validated using Zod schemas from src/shared/validation/schemas.ts. Instead of using custom regexes, you should define and export Zod schemas for the locale and slug in src/shared/validation/schemas.ts, and import them here and in the production code for validation.
References
  1. Always validate inputs with Zod schemas from src/shared/validation/schemas.ts.


test("Locale and slug regex blocks common traversal patterns", () => {
const badLocales = ["..", "../..", "/etc", "es/../../etc", "es\0"];
const badSlugs = [[".."], ["..", "foo"], ["/etc", "passwd"], ["foo", "..", "bar"]];

for (const locale of badLocales) {
assert.strictEqual(LOCALE_REGEX.test(locale), false, `Should block bad locale: ${locale}`);
}

for (const slug of badSlugs) {
const allOk = slug.every(s => SLUG_REGEX.test(s));
assert.strictEqual(allOk, false, `Should block bad slug: ${slug.join("/")}`);
}
});

test("Locale and slug regex allows valid patterns", () => {
const goodLocales = ["en", "pt-BR", "zh-CN", "es"];
const goodSlugs = [["getting-started"], ["architecture", "overview"], ["v1-migration"]];

for (const locale of goodLocales) {
assert.strictEqual(LOCALE_REGEX.test(locale), true, `Should allow good locale: ${locale}`);
}

for (const slug of goodSlugs) {
const allOk = slug.every(s => SLUG_REGEX.test(s));
assert.strictEqual(allOk, true, `Should allow good slug: ${slug.join("/")}`);
}
});
27 changes: 27 additions & 0 deletions tests/unit/security/docs-sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeDocsHtml } from "../../../src/lib/docsSanitizer.ts";

test("sanitizeDocsHtml removes dangerous tags and attributes", () => {
const malicious = '<script>alert("xss")</script><img src="x" onerror="alert(1)"> <a href="javascript:alert(1)">link</a> <div onclick="evil()">content</div>';
const sanitized = sanitizeDocsHtml(malicious);

assert.ok(!sanitized.includes("<script>"), "Should remove <script>");
assert.ok(!sanitized.includes("onerror"), "Should remove onerror");
assert.ok(!sanitized.includes("onclick"), "Should remove onclick");
assert.ok(!sanitized.includes("javascript:"), "Should remove javascript: URLs");
assert.ok(sanitized.includes("<a>link</a>") || sanitized.includes("<a >link</a>") || sanitized.includes("link"), "Should keep safe content");
});

test("sanitizeDocsHtml allows safe tags and attributes", () => {
const safe = '<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>Item</li></ul><pre><code>code</code></pre>';
const sanitized = sanitizeDocsHtml(safe);

assert.ok(sanitized.includes("<h1>Title</h1>"), "Should allow <h1>");
assert.ok(sanitized.includes("<p>"), "Should allow <p>");
assert.ok(sanitized.includes("<strong>"), "Should allow <strong>");
assert.ok(sanitized.includes("<ul>"), "Should allow <ul>");
assert.ok(sanitized.includes("<li>"), "Should allow <li>");
assert.ok(sanitized.includes("<pre>"), "Should allow <pre>");
assert.ok(sanitized.includes("<code>"), "Should allow <code>");
});