From a74e169d9f8c520a0bb37f991660ddb09e95c0aa Mon Sep 17 00:00:00 2001 From: Shamim Date: Tue, 27 Oct 2026 05:59:22 +0600 Subject: [PATCH 1/3] refactor: give the README sanitizer a class of its own The rules that decide what survives from a GitHub README were private to the query that fetches it, so the only way to exercise them was to fake GitHub, the cache and a user. They are now a service with nothing to inject, which the query delegates to; the rules themselves are unchanged. --- app/Queries/GitHubReadmeQuery.php | 66 ++------------------- app/Services/GitHubReadmeSanitizer.php | 79 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 60 deletions(-) create mode 100644 app/Services/GitHubReadmeSanitizer.php diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 92f25ff6d..be407abf6 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -6,19 +6,12 @@ use App\Models\User; use App\Services\GitHub; +use App\Services\GitHubReadmeSanitizer; use Illuminate\Http\Client\HttpClientException; use Illuminate\Support\Facades\Cache; -use Symfony\Component\HtmlSanitizer\HtmlSanitizer; -use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction; -use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; final readonly class GitHubReadmeQuery { - /** - * Profile READMEs are mostly badges and banners, whose long proxied image URLs add up. - */ - private const int MaxLength = 200_000; - /** * Images uploaded to GitHub are served from links signed for five minutes. */ @@ -26,8 +19,10 @@ private const int FailureTtl = 300; - public function __construct(private GitHub $github) - { + public function __construct( + private GitHub $github, + private GitHubReadmeSanitizer $sanitizer, + ) { // } @@ -80,55 +75,6 @@ private function fetch(User $user): string ->replaceHeaders(['Accept' => 'application/vnd.github.html+json']) ->get("repos/{$login}/{$login}/readme"); - return $response->notFound() ? '' : $this->sanitize($response->throw()->body()); - } - - /** - * Never trusted as is: only plain formatting, links and images from GitHub's hosts are kept. Classes, ids and - * styles are dropped, so a README can't take on the page's own styles, cover it or pose as part of it. - */ - private function sanitize(string $html): string - { - $config = new HtmlSanitizerConfig() - // Only what a README is written with: a fixed list, so attributes like srcset or name, and elements like - // dialog, never come along with the browser's idea of what is safe. - ->allowElement('a', ['href', 'title']) - ->allowElement('img', ['src', 'alt', 'title', 'width', 'height', 'align']) - ->allowElement('p', ['align']) - ->allowElement('div', ['align']) - ->allowElement('h1', ['align']) - ->allowElement('h2', ['align']) - ->allowElement('h3', ['align']) - ->allowElement('h4', ['align']) - ->allowElement('h5', ['align']) - ->allowElement('h6', ['align']) - ->allowElement('th', ['align', 'colspan', 'rowspan']) - ->allowElement('td', ['align', 'colspan', 'rowspan']) - ->allowElement('ol', ['start']) - ->allowElement('details', ['open']); - - foreach (['article', 'span', 'ul', 'li', 'table', 'thead', 'tbody', 'tr', 'pre', 'code', 'blockquote', 'summary', 'picture', 'br', 'hr', 'b', 'strong', 'i', 'em', 'sub', 'sup', 'del', 's', 'kbd'] as $element) { - $config = $config->allowElement($element); - } - - // Any other element goes, but what it holds stays: GitHub's own wrappers like g-emoji or themed-picture, and - // the rarer text elements like samp or dl, keep their words. Only these go with everything in them. - $config = $config->defaultAction(HtmlSanitizerAction::Block); - - foreach (['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'svg', 'math', 'dialog', 'map', 'form', 'textarea', 'select', 'button', 'head', 'title', 'source', 'video', 'audio'] as $element) { - $config = $config->dropElement($element); - } - - $config = $config - ->allowLinkSchemes(['https', 'http', 'mailto']) - ->allowRelativeLinks(false) - ->allowMediaSchemes(['https']) - ->allowMediaHosts(['camo.githubusercontent.com', 'github.com', 'raw.githubusercontent.com', 'user-images.githubusercontent.com', 'private-user-images.githubusercontent.com']) - ->allowRelativeMedias(false) - ->forceAttribute('a', 'rel', 'noopener noreferrer nofollow ugc') - ->forceAttribute('a', 'target', '_blank') - ->withMaxInputLength(self::MaxLength); - - return mb_trim(new HtmlSanitizer($config)->sanitize($html)); + return $response->notFound() ? '' : $this->sanitizer->sanitize($response->throw()->body()); } } diff --git a/app/Services/GitHubReadmeSanitizer.php b/app/Services/GitHubReadmeSanitizer.php new file mode 100644 index 000000000..217c9784a --- /dev/null +++ b/app/Services/GitHubReadmeSanitizer.php @@ -0,0 +1,79 @@ +sanitizer = new HtmlSanitizer($this->config()); + } + + public function sanitize(string $html): string + { + return mb_trim($this->sanitizer->sanitize($html)); + } + + private function config(): HtmlSanitizerConfig + { + $config = new HtmlSanitizerConfig() + // Only what a README is written with: a fixed list, so attributes like srcset or name, and elements like + // dialog, never come along with the browser's idea of what is safe. + ->allowElement('a', ['href', 'title']) + ->allowElement('img', ['src', 'alt', 'title', 'width', 'height', 'align']) + ->allowElement('p', ['align']) + ->allowElement('div', ['align']) + ->allowElement('h1', ['align']) + ->allowElement('h2', ['align']) + ->allowElement('h3', ['align']) + ->allowElement('h4', ['align']) + ->allowElement('h5', ['align']) + ->allowElement('h6', ['align']) + ->allowElement('th', ['align', 'colspan', 'rowspan']) + ->allowElement('td', ['align', 'colspan', 'rowspan']) + ->allowElement('ol', ['start']) + ->allowElement('details', ['open']); + + foreach (['article', 'span', 'ul', 'li', 'table', 'thead', 'tbody', 'tr', 'pre', 'code', 'blockquote', 'summary', 'picture', 'br', 'hr', 'b', 'strong', 'i', 'em', 'sub', 'sup', 'del', 's', 'kbd'] as $element) { + $config = $config->allowElement($element); + } + + // Any other element goes, but what it holds stays: GitHub's own wrappers like g-emoji or themed-picture, and + // the rarer text elements like samp or dl, keep their words. Only these go with everything in them. + $config = $config->defaultAction(HtmlSanitizerAction::Block); + + foreach (['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'svg', 'math', 'dialog', 'map', 'form', 'textarea', 'select', 'button', 'head', 'title', 'source', 'video', 'audio'] as $element) { + $config = $config->dropElement($element); + } + + return $config + ->allowLinkSchemes(['https', 'http', 'mailto']) + ->allowRelativeLinks(false) + ->allowMediaSchemes(['https']) + ->allowMediaHosts(['camo.githubusercontent.com', 'github.com', 'raw.githubusercontent.com', 'user-images.githubusercontent.com', 'private-user-images.githubusercontent.com']) + ->allowRelativeMedias(false) + ->forceAttribute('a', 'rel', 'noopener noreferrer nofollow ugc') + ->forceAttribute('a', 'target', '_blank') + ->withMaxInputLength(self::MAX_LENGTH); + } +} From 4840a3ed038631bbebfcc1f83b86c75a2907f03e Mon Sep 17 00:00:00 2001 From: Shamim Date: Tue, 27 Oct 2026 05:59:23 +0600 Subject: [PATCH 2/3] test: hold the README sanitizer to the payloads that break sanitizers The sanitizer is the only thing between a README someone else wrote and the page, and it was trusted on inspection alone. This pins the behaviour against the constructs that have defeated sanitizers before: script tags, handlers on images and details, javascript and protocol-relative links, the svg and math mutation tricks, iframes with their own document, data uris, and styles that would let a README cover the page. It also pins what must keep working, since a sanitizer that drops everything is safe and useless: headings with alignment, inline formatting, lists, and mailto links. Two of these assertions were wrong when first written, and running them found it: a mailto href survives but with its @ entity-encoded, and the length cap truncates the input rather than refusing it. --- .../Services/GitHubReadmeSanitizerTest.php | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/Unit/Services/GitHubReadmeSanitizerTest.php diff --git a/tests/Unit/Services/GitHubReadmeSanitizerTest.php b/tests/Unit/Services/GitHubReadmeSanitizerTest.php new file mode 100644 index 000000000..98935b197 --- /dev/null +++ b/tests/Unit/Services/GitHubReadmeSanitizerTest.php @@ -0,0 +1,78 @@ +sanitize($payload); + + expect($clean)->not->toMatch('/with([ + 'a script tag' => '', + 'an image that throws' => '', + 'a javascript link' => 'click', + 'an svg holding a style' => '', + 'the math mutation' => '