From 58bddf6b6eced68a7c9d7c31f57af6e9b716b5f3 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:11:25 +0200 Subject: [PATCH] [docs-infra] Harden the OG image edge function Three defense-in-depth changes to the public, unauthenticated /edge-functions/og-image endpoint (all the same file, so grouped): - Pin the `og_edge` import (was an unversioned deno.land URL that resolves to latest) so an upstream release can't change a build without review. - Bound query-controlled inputs before they reach the image renderer: title/description/product length, author-list size, author name length, and GitHub username format. - Add Netlify rate limiting (100 req/min per ip+domain) so a client can't force unbounded render work by varying query parameters. --- netlify/edge-functions/og-image.tsx | 72 +++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/netlify/edge-functions/og-image.tsx b/netlify/edge-functions/og-image.tsx index 398cacb6e423d2..ba48b2b3329b4e 100644 --- a/netlify/edge-functions/og-image.tsx +++ b/netlify/edge-functions/og-image.tsx @@ -1,26 +1,54 @@ import React from 'https://esm.sh/react@18.2.0'; // eslint-disable-next-line import/extensions -import { ImageResponse } from 'https://deno.land/x/og_edge/mod.ts'; +import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.6/mod.ts'; +// The image only ever shows 5 authors (2 rendered rows), so this one is a layout cap, not a +// safety limit. The length caps below are deliberately wide — pure backstops against a crafted +// URL blowing up the render, sitting far above any real title/description/name. const MAX_AUTHORS = 5; -export default async function handler(req: Request) { - const params = new URL(req.url).searchParams; - const title = params.get('title'); - const authors = params.get('authors'); - const product = params.get('product'); - const description = params.get('description'); +const MAX_TITLE_LENGTH = 1000; +const MAX_DESCRIPTION_LENGTH = 1000; +const MAX_PRODUCT_LENGTH = 200; +const MAX_AUTHORS_PARAMETER_LENGTH = 2000; +const MAX_AUTHOR_NAME_LENGTH = 200; +const GITHUB_USERNAME = /^[A-Za-z\d](?:[A-Za-z\d-]{0,37}[A-Za-z\d])?$/; + +function truncate(value: string | null, maxLength: number) { + return value?.slice(0, maxLength); +} - const parsedAuthors = - authors && - authors - .split(',') - .map((author) => { - const [name, github] = author.split('@'); - return { name: name.trim(), github: github.trim() }; - }) - .filter(({ name, github }) => name && github); +// Bound query-controlled inputs before they reach the image renderer, so a crafted URL can't +// drive unbounded layout/render work via many title words, huge text fields, or a long author list. +function getOgImageParams(url: string) { + const params = new URL(url).searchParams; + const rawAuthors = truncate(params.get('authors'), MAX_AUTHORS_PARAMETER_LENGTH); + const authors = rawAuthors + ? rawAuthors + .split(',', MAX_AUTHORS) + .map((author) => { + const [name, github] = author.split('@', 2).map((part) => part.trim()); + if (!name || !github || !GITHUB_USERNAME.test(github)) { + return null; + } + return { + name: name.slice(0, MAX_AUTHOR_NAME_LENGTH), + github, + }; + }) + .filter((author) => author !== null) + : []; - const withAuthors = parsedAuthors && parsedAuthors.length > 0; + return { + title: truncate(params.get('title'), MAX_TITLE_LENGTH), + description: truncate(params.get('description'), MAX_DESCRIPTION_LENGTH), + product: truncate(params.get('product'), MAX_PRODUCT_LENGTH), + authors, + }; +} + +export default async function handler(req: Request) { + const { title, authors, product, description } = getOgImageParams(req.url); + const withAuthors = authors.length > 0; let starCount = 0; return new ImageResponse( @@ -155,7 +183,7 @@ export default async function handler(req: Request) { }} > {withAuthors && - parsedAuthors.slice(0, MAX_AUTHORS).map(({ name, github }) => { + authors.map(({ name, github }) => { return (