From 97d3b949b72f0c26d8d8cc22923f5263a027cc6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:10:07 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Renforcement=20de=20la=20s=C3=A9curit=C3=A9?= =?UTF-8?q?=20:=20rate=20limiting,=20durcissement=20OG,=20headers,=20secur?= =?UTF-8?q?ity.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Limitation de débit (par IP + globale) sur l'action d'envoi de CV pour bloquer le spam d'e-mails via l'action serveur publique - Messages d'erreur serveur masqués par défaut (ActionError pour les messages sûrs) dans le client next-safe-action - Schéma e-mail durci : prénom trim + caractères autorisés, e-mail max 254 - Route /api/og : titre/description bornés et nettoyés des caractères de contrôle (anti-abus de génération d'images) - Nouveaux headers : HSTS, CSP (base-uri, frame-ancestors, object-src, form-action), COOP, X-DNS-Prefetch-Control - /.well-known/security.txt (RFC 9116) - Lecture des variables d'env via src/env.ts au lieu de process.env (octokit, data.action, commit.action) - Tests unitaires du rate limiter https://claude.ai/code/session_01SKa4Csg7HxNxwrt4knWE7r --- next.config.ts | 17 +++++++ public/.well-known/security.txt | 4 ++ src/actions/commit.action.ts | 5 +- src/actions/data.action.ts | 5 +- src/actions/safe-action.ts | 18 ++++++- src/actions/send-cv.action.ts | 31 +++++++++++- src/app/api/og/route.tsx | 32 ++++++++++-- src/lib/octokit.ts | 4 +- src/lib/rate-limit.test.ts | 86 +++++++++++++++++++++++++++++++++ src/lib/rate-limit.ts | 70 +++++++++++++++++++++++++++ src/schemas/emailSchema.ts | 13 ++++- test-results/.last-run.json | 2 +- 12 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 public/.well-known/security.txt create mode 100644 src/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/next.config.ts b/next.config.ts index 7e676d4..3936e7b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,23 @@ const nextConfig: NextConfig = { return [ { headers: [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Content-Security-Policy", + value: + "base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "X-DNS-Prefetch-Control", + value: "on", + }, { key: "X-Frame-Options", value: "DENY", diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..eac1b95 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:contact@cuzeacflorin.fr +Expires: 2027-06-09T00:00:00.000Z +Preferred-Languages: fr, en +Canonical: https://cuzeacflorin.fr/.well-known/security.txt diff --git a/src/actions/commit.action.ts b/src/actions/commit.action.ts index 5ad15aa..377989f 100644 --- a/src/actions/commit.action.ts +++ b/src/actions/commit.action.ts @@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"; +import { env } from "@/env"; import { logger } from "@/lib/logger"; import { octokit } from "@/lib/octokit"; @@ -25,8 +26,8 @@ const fetchCommitData = async (): Promise => { } }`, { - owner: process.env.GITHUB_USERNAME, - repo: process.env.GITHUB_REPO_NAME, + owner: env.GITHUB_USERNAME, + repo: env.GITHUB_REPO_NAME, } ); diff --git a/src/actions/data.action.ts b/src/actions/data.action.ts index 654ea98..26db4f0 100644 --- a/src/actions/data.action.ts +++ b/src/actions/data.action.ts @@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"; +import { env } from "@/env"; import { contributionLevelToNumber } from "@/lib/commits"; import { octokit } from "@/lib/octokit"; @@ -49,8 +50,8 @@ const fetchGitHubData = async (): Promise => { }`, { from: from.toISOString(), - owner: process.env.GITHUB_USERNAME, - repo: process.env.GITHUB_REPO_NAME, + owner: env.GITHUB_USERNAME, + repo: env.GITHUB_REPO_NAME, to: to.toISOString(), } ); diff --git a/src/actions/safe-action.ts b/src/actions/safe-action.ts index 7f62198..4ac6372 100644 --- a/src/actions/safe-action.ts +++ b/src/actions/safe-action.ts @@ -1,3 +1,17 @@ -import { createSafeActionClient } from "next-safe-action"; +import { + createSafeActionClient, + DEFAULT_SERVER_ERROR_MESSAGE, +} from "next-safe-action"; -export const actionClient = createSafeActionClient(); +// Seuls les messages levés via ActionError sont renvoyés au client, +// le reste est masqué pour ne pas fuiter de détails serveur. +export class ActionError extends Error { + override name = "ActionError"; +} + +export const actionClient = createSafeActionClient({ + handleServerError: (error) => + error instanceof ActionError + ? error.message + : DEFAULT_SERVER_ERROR_MESSAGE, +}); diff --git a/src/actions/send-cv.action.ts b/src/actions/send-cv.action.ts index e545bfe..b432713 100644 --- a/src/actions/send-cv.action.ts +++ b/src/actions/send-cv.action.ts @@ -3,21 +3,48 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { headers } from "next/headers"; import { Resend } from "resend"; -import { actionClient } from "@/actions/safe-action"; +import { ActionError, actionClient } from "@/actions/safe-action"; import { CvTemplate } from "@/app/(fr)/(content)/(root)/_components/cv/CvTemplate"; import GLOBAL_DATA from "@/data/global"; import { env } from "@/env"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import { emailSchema } from "@/schemas/emailSchema"; -const resend = new Resend(env.RESEND_API_KEY); +const RATE_LIMIT_PER_IP = { limit: 3, windowMs: 10 * 60 * 1000 }; +const RATE_LIMIT_GLOBAL = { limit: 20, windowMs: 60 * 60 * 1000 }; export const sendCvAction = actionClient .inputSchema(emailSchema) .action(async ({ parsedInput }) => { const { firstName, recipientEmail } = parsedInput; + const clientIp = getClientIp(await headers()); + const perIp = checkRateLimit( + `send-cv:ip:${clientIp}`, + RATE_LIMIT_PER_IP + ); + const global = checkRateLimit( + "send-cv:global", + RATE_LIMIT_GLOBAL + ); + + if (!(perIp.allowed && global.allowed)) { + throw new ActionError( + "Trop de demandes, réessayez dans quelques minutes !" + ); + } + + if (!env.RESEND_API_KEY) { + throw new ActionError( + "L'envoi d'e-mails n'est pas disponible pour le moment !" + ); + } + + const resend = new Resend(env.RESEND_API_KEY); + const path = join( process.cwd(), "public", diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx index c22ec7c..ada336e 100644 --- a/src/app/api/og/route.tsx +++ b/src/app/api/og/route.tsx @@ -54,6 +54,24 @@ const getBadge = (type: PageType): string => { const OG_DIMENSIONS = { height: 630, width: 1200 } as const; +const MAX_TITLE_LENGTH = 120; +const MAX_DESCRIPTION_LENGTH = 280; + +const sanitizeParam = ( + value: string | null, + maxLength: number, + fallback: string +): string => { + const cleaned = value?.replaceAll(/\p{C}+/gu, " ").trim() ?? ""; + if (!cleaned) { + return fallback; + } + if (cleaned.length <= maxLength) { + return cleaned; + } + return `${cleaned.slice(0, maxLength)}…`; +}; + const renderLayout = ( content: JSX.Element, fontFamily = "sans-serif" @@ -127,10 +145,16 @@ export const GET = async (req: NextRequest) => { const type: PageType = isValidPageType(rawType) ? rawType : "homepage"; - const title = - searchParams.get("title") || GLOBAL_DATA.USER.fullName; - const description = - searchParams.get("description") || GLOBAL_DATA.USER.bio; + const title = sanitizeParam( + searchParams.get("title"), + MAX_TITLE_LENGTH, + GLOBAL_DATA.USER.fullName + ); + const description = sanitizeParam( + searchParams.get("description"), + MAX_DESCRIPTION_LENGTH, + GLOBAL_DATA.USER.bio + ); const font = await loadFont(); const badge = getBadge(type); diff --git a/src/lib/octokit.ts b/src/lib/octokit.ts index 4bdcd38..ce08ac7 100644 --- a/src/lib/octokit.ts +++ b/src/lib/octokit.ts @@ -1,7 +1,9 @@ import { Octokit } from "octokit"; +import { env } from "@/env"; + const github = new Octokit({ - auth: process.env.GITHUB_API_TOKEN, + auth: env.GITHUB_API_TOKEN, timeZone: "UTC", userAgent: "envindavsorg", }); diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..f36d544 --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,86 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + checkRateLimit, + getClientIp, + resetRateLimits, +} from "@/lib/rate-limit"; + +const OPTIONS = { limit: 3, windowMs: 60_000 }; + +describe("checkRateLimit", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetRateLimits(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows calls under the limit", () => { + expect(checkRateLimit("key", OPTIONS).allowed).toBe(true); + expect(checkRateLimit("key", OPTIONS).allowed).toBe(true); + const third = checkRateLimit("key", OPTIONS); + expect(third.allowed).toBe(true); + expect(third.remaining).toBe(0); + }); + + it("blocks calls over the limit and reports retry delay", () => { + checkRateLimit("key", OPTIONS); + checkRateLimit("key", OPTIONS); + checkRateLimit("key", OPTIONS); + + const blocked = checkRateLimit("key", OPTIONS); + expect(blocked.allowed).toBe(false); + expect(blocked.remaining).toBe(0); + expect(blocked.retryAfterMs).toBeGreaterThan(0); + expect(blocked.retryAfterMs).toBeLessThanOrEqual( + OPTIONS.windowMs + ); + }); + + it("tracks keys independently", () => { + checkRateLimit("a", OPTIONS); + checkRateLimit("a", OPTIONS); + checkRateLimit("a", OPTIONS); + + expect(checkRateLimit("a", OPTIONS).allowed).toBe(false); + expect(checkRateLimit("b", OPTIONS).allowed).toBe(true); + }); + + it("allows again once the window has elapsed", () => { + checkRateLimit("key", OPTIONS); + checkRateLimit("key", OPTIONS); + checkRateLimit("key", OPTIONS); + expect(checkRateLimit("key", OPTIONS).allowed).toBe(false); + + vi.advanceTimersByTime(OPTIONS.windowMs + 1); + expect(checkRateLimit("key", OPTIONS).allowed).toBe(true); + }); +}); + +describe("getClientIp", () => { + it("reads the first x-forwarded-for entry", () => { + const headers = new Headers({ + "x-forwarded-for": "203.0.113.7, 10.0.0.1", + }); + expect(getClientIp(headers)).toBe("203.0.113.7"); + }); + + it("falls back to x-real-ip", () => { + const headers = new Headers({ "x-real-ip": "203.0.113.8" }); + expect(getClientIp(headers)).toBe("203.0.113.8"); + }); + + it("returns unknown without client headers", () => { + expect(getClientIp(new Headers())).toBe("unknown"); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..05ef9c9 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,70 @@ +interface RateLimitOptions { + /** Nombre maximum d'appels autorisés dans la fenêtre. */ + limit: number; + /** Durée de la fenêtre en millisecondes. */ + windowMs: number; +} + +interface RateLimitResult { + allowed: boolean; + remaining: number; + retryAfterMs: number; +} + +// Limiteur en mémoire : l'état est local à chaque instance serveur, +// suffisant pour freiner l'abus des actions publiques d'un portfolio. +const MAX_TRACKED_KEYS = 10_000; + +const hits = new Map(); + +export const checkRateLimit = ( + key: string, + { limit, windowMs }: RateLimitOptions +): RateLimitResult => { + const now = Date.now(); + const windowStart = now - windowMs; + const timestamps = (hits.get(key) ?? []).filter( + (timestamp) => timestamp > windowStart + ); + + if (timestamps.length >= limit) { + hits.set(key, timestamps); + return { + allowed: false, + remaining: 0, + retryAfterMs: timestamps[0] + windowMs - now, + }; + } + + if (!hits.has(key) && hits.size >= MAX_TRACKED_KEYS) { + const oldestKey = hits.keys().next().value; + if (oldestKey !== undefined) { + hits.delete(oldestKey); + } + } + + timestamps.push(now); + hits.set(key, timestamps); + + return { + allowed: true, + remaining: limit - timestamps.length, + retryAfterMs: 0, + }; +}; + +export const getClientIp = (headers: Headers): string => { + const forwarded = headers.get("x-forwarded-for"); + if (forwarded) { + const [firstIp] = forwarded.split(","); + if (firstIp?.trim()) { + return firstIp.trim(); + } + } + + return headers.get("x-real-ip")?.trim() || "unknown"; +}; + +export const resetRateLimits = (): void => { + hits.clear(); +}; diff --git a/src/schemas/emailSchema.ts b/src/schemas/emailSchema.ts index f50d99a..f7f989c 100644 --- a/src/schemas/emailSchema.ts +++ b/src/schemas/emailSchema.ts @@ -1,12 +1,21 @@ import { z } from "zod"; +const FIRST_NAME_REGEX = /^[\p{L}\p{M}' -]+$/u; + export const emailSchema = z.object({ firstName: z .string() + .trim() .min(1, "Le prénom est requis !") .min(2, "Le prénom doit contenir au moins 2 caractères !") - .max(20, "Le prénom doit contenir moins de 20 caractères !"), - recipientEmail: z.email("Adresse e-mail obligatoire !"), + .max(20, "Le prénom doit contenir moins de 20 caractères !") + .regex( + FIRST_NAME_REGEX, + "Le prénom contient des caractères non autorisés !" + ), + recipientEmail: z + .email("Adresse e-mail obligatoire !") + .max(254, "L'adresse e-mail est trop longue !"), }); export type EmailFormData = z.infer; diff --git a/test-results/.last-run.json b/test-results/.last-run.json index cbcc1fb..f740f7c 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,4 +1,4 @@ { "status": "passed", "failedTests": [] -} \ No newline at end of file +} From 7fa77fc1ebbff3669fec3ce256306b079e19b492 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:28:20 +0000 Subject: [PATCH 2/6] =?UTF-8?q?Nouvelles=20fonctionnalit=C3=A9s=20:=20arti?= =?UTF-8?q?cles=20li=C3=A9s,=20progression=20de=20lecture,=20resume.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WritingsRelated : suggestions « à lire ensuite » en bas des articles, composants et outils, classées par tags partagés puis par date (lib getRelatedContent testée unitairement), FR + EN - WritingsProgress : barre de progression de lecture en haut des pages de contenu (motion, useScroll + useSpring) - /resume.json : CV machine-readable au format JSON Resume, référencé dans /llms.txt https://claude.ai/code/session_01SKa4Csg7HxNxwrt4knWE7r --- messages/en.json | 2 + messages/fr.json | 2 + .../(writings)/articles/[slug]/page.tsx | 4 + .../(writings)/components/[slug]/page.tsx | 4 + .../(writings)/utils/[slug]/page.tsx | 6 ++ src/app/(llms)/llms.txt/route.ts | 2 + src/app/(llms)/resume.json/route.ts | 47 ++++++++++ src/components/features/WritingsProgress.tsx | 20 ++++ src/components/features/WritingsRelated.tsx | 61 +++++++++++++ src/lib/related.test.ts | 91 +++++++++++++++++++ src/lib/related.ts | 30 ++++++ 11 files changed, 269 insertions(+) create mode 100644 src/app/(llms)/resume.json/route.ts create mode 100644 src/components/features/WritingsProgress.tsx create mode 100644 src/components/features/WritingsRelated.tsx create mode 100644 src/lib/related.test.ts create mode 100644 src/lib/related.ts diff --git a/messages/en.json b/messages/en.json index 5d5b79e..3385ad7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -290,6 +290,8 @@ "writings_pagination_next_sr": "next", "writings_pagination_previous_aria": "previous", "writings_pagination_previous_sr": "previous", + "writings_related_heading": "read next:", + "writings_related_item_aria": "Read: {title}", "writings_tags_by_category": "by category:", "writings_toc_trigger_label": "key points on this page", "writings_utils_description": "a suite of free web tools designed to optimize your workflow and boost your productivity", diff --git a/messages/fr.json b/messages/fr.json index c056260..aeac06f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -290,6 +290,8 @@ "writings_pagination_next_sr": "Suivant", "writings_pagination_previous_aria": "Précédent", "writings_pagination_previous_sr": "Précédent", + "writings_related_heading": "à lire ensuite :", + "writings_related_item_aria": "Lire : {title}", "writings_tags_by_category": "par catégorie :", "writings_toc_trigger_label": "points importants sur cette page", "writings_utils_description": "Une suite d'outils web gratuits conçus pour optimiser votre workflow et booster votre productivité", diff --git a/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx index f14b1eb..5fdc4fb 100644 --- a/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx @@ -3,6 +3,8 @@ import { notFound } from "next/navigation"; import { ArticleTitle } from "@/components/blog/ArticleTitle"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; import { Mdx } from "@/components/markdown/mdx"; @@ -68,10 +70,12 @@ export const ArticleView = ({ {locale === "en" && article.locale === "fr" && ( )} + + ); }; diff --git a/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx index 3fe1195..33a45ef 100644 --- a/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx @@ -3,6 +3,8 @@ import { notFound } from "next/navigation"; import { ArticleTitle } from "@/components/blog/ArticleTitle"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; import { Mdx } from "@/components/markdown/mdx"; @@ -72,6 +74,7 @@ export const ComponentView = ({ {locale === "en" && component.locale === "fr" && ( )} + + ); }; diff --git a/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx index 6c0a6bf..b6b8e09 100644 --- a/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx @@ -5,6 +5,8 @@ import { Divider } from "@/components/base/Divider"; import { WritingsBreadcrumb } from "@/components/features/WritingsBreadcrumb"; import { WritingsHeading } from "@/components/features/WritingsHeading"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; import { Mdx } from "@/components/markdown/mdx"; @@ -89,8 +91,12 @@ export const UtilView = ({ + + + +