Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@
**Vulnerability:** Running `pnpm install` in the agent environment caused an unintended downgrade of `lucide-react` in `pnpm-lock.yaml`.
**Learning:** The agent's global or cached `pnpm` version/config might conflict with the project's lockfile, especially when specific versions are pinned in `package.json`.
**Prevention:** Always verify lockfile changes after installation and use `git checkout` to restore unrelated changes if they occur.

## 2026-07-05 - [XSS in Custom Markdown Renderer]

**Vulnerability:** The custom markdown-lite renderer in `blog.$slug.tsx` was using `dangerouslySetInnerHTML` without escaping the input text, allowing arbitrary HTML injection.
**Learning:** Custom renderers using `dangerouslySetInnerHTML` must escape all HTML special characters (&, <, >, ", ') *before* applying safe transformations (like regex-based bolding or links) to ensure defense-in-depth.
**Prevention:** Use a shared `escapeHTML` utility and a "strict escape first, then transform" pattern for any manual HTML construction.
9 changes: 9 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ export function sanitizeNumero(numero: unknown): string {
return "";
}

export function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

export function normalize(s: string): string {
if (!s || typeof s !== "string") return "";
return s
Expand Down
10 changes: 10 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,13 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
23 changes: 22 additions & 1 deletion src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,35 @@ export function createSeoMeta(config: SeoConfig) {
* Sécurise une chaîne JSON pour injection dans une balise <script type="application/ld+json">.
* Échappe les caractères '<', '>', '&' pour éviter l'injection de scripts (XSS).
*/
function safeJsonLd(json: string): string {
export function safeJsonLd(json: string): string {
return json
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\//g, "\\/");
}

export function createArticleSchema(article: {
title: string;
description: string;
date: string;
author: string;
url: string;
}) {
return safeJsonLd(
JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.description,
datePublished: article.date,
author: { "@type": "Organization", name: article.author },
publisher: { "@type": "Organization", name: "Mandat" },
mainEntityOfPage: article.url,
}),
);
}

export function createBreadcrumbSchema(
breadcrumbs: Array<{ name: string; url: string }>,
) {
Expand Down
29 changes: 14 additions & 15 deletions src/routes/blog.$slug.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { Calendar, Clock, ArrowLeft } from "lucide-react";
import { getPostBySlug, getAllPosts, type BlogPost } from "@/lib/blog";
import { createSeoMeta, SITE_URL } from "./__root";
import { escapeHTML } from "@/lib/api";
import { createSeoMeta, createArticleSchema, SITE_URL } from "./__root";

export const Route = createFileRoute("/blog/$slug")({
loader: ({ params }) => {
Expand All @@ -25,15 +26,12 @@ export const Route = createFileRoute("/blog/$slug")({
scripts: [
{
type: "application/ld+json",
children: JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
children: createArticleSchema({
title: post.title,
description: post.description,
datePublished: post.date,
author: { "@type": "Organization", name: post.author },
publisher: { "@type": "Organization", name: "Mandat" },
mainEntityOfPage: url,
date: post.date,
author: post.author,
url,
}),
},
],
Expand Down Expand Up @@ -71,7 +69,7 @@ function renderContent(md: string) {
<li
key={j}
dangerouslySetInnerHTML={{
__html: it.replace(
__html: escapeHTML(it).replace(
/\*\*(.+?)\*\*/g,
'<strong class="text-ink">$1</strong>',
),
Expand All @@ -86,11 +84,12 @@ function renderContent(md: string) {
key={i}
className="my-5 text-lg leading-relaxed text-ink/85"
dangerouslySetInnerHTML={{
__html: b
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" class="text-primary underline underline-offset-4 hover:opacity-80">$1</a>',
)
__html: escapeHTML(b)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) => {
const safeUrl = url.replace(/["'<>]/g, "").trim();
if (safeUrl.toLowerCase().startsWith("javascript:")) return text;
return `<a href="${safeUrl}" class="text-primary underline underline-offset-4 hover:opacity-80">${text}</a>`;
})
.replace(
/\*\*(.+?)\*\*/g,
'<strong class="text-ink font-medium">$1</strong>',
Expand Down