diff --git a/backend/app/Models/Document.php b/backend/app/Models/Document.php index eb20772..b93f050 100644 --- a/backend/app/Models/Document.php +++ b/backend/app/Models/Document.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Services\Markdown\HtmlSanitizer; use Database\Factories\DocumentFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -37,6 +39,22 @@ protected function casts(): array ]; } + /** + * Sanitizes `content_html` on every read so the API never returns + * script-capable markup, regardless of how the stored value was written + * (ingestion pipeline, factory, seed, or a row inserted before the + * sanitizer existed). The stored value itself is never mutated by this + * accessor. + * + * @return Attribute + */ + protected function contentHtml(): Attribute + { + return Attribute::make( + get: fn (?string $value): string => HtmlSanitizer::sanitize($value), + ); + } + /** * @return BelongsTo */ diff --git a/backend/app/Services/Markdown/HtmlSanitizer.php b/backend/app/Services/Markdown/HtmlSanitizer.php new file mode 100644 index 0000000..6c887c8 --- /dev/null +++ b/backend/app/Services/Markdown/HtmlSanitizer.php @@ -0,0 +1,190 @@ +> + */ + private const ALLOWED_TAGS = [ + 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [], + 'p' => [], 'br' => [], 'hr' => [], + 'strong' => [], 'em' => [], 'b' => [], 'i' => [], 's' => [], 'del' => [], + 'blockquote' => [], + 'pre' => [], + 'code' => ['class'], + 'ul' => [], 'ol' => [], 'li' => [], + 'table' => [], 'thead' => [], 'tbody' => [], 'tfoot' => [], + 'tr' => [], + 'th' => ['align'], + 'td' => ['align'], + 'a' => ['href', 'title'], + 'img' => ['src', 'alt', 'title'], + 'input' => ['type', 'checked', 'disabled'], + ]; + + /** + * URL schemes allowed for `href`/`src`. Everything else (javascript:, data:, + * vbscript:, file:, etc.) is stripped. Schemeless values (relative paths, + * fragments, query strings) are always allowed. + * + * @var list + */ + private const ALLOWED_URL_SCHEMES = ['http', 'https', 'mailto']; + + /** + * @var list + */ + private const ALLOWED_ALIGN_VALUES = ['left', 'center', 'right', 'justify', 'char']; + + private const ROOT_ID = '__command_sphere_html_sanitizer_root__'; + + /** + * Sanitizes an HTML fragment down to the allowlisted subset. + */ + public static function sanitize(?string $html): string + { + $html = (string) $html; + + if (trim($html) === '') { + return ''; + } + + $dom = new DOMDocument; + $previousErrorSetting = libxml_use_internal_errors(true); + + $loaded = $dom->loadHTML( + '
'.$html.'
', + LIBXML_NOERROR | LIBXML_NOWARNING + ); + + libxml_clear_errors(); + libxml_use_internal_errors($previousErrorSetting); + + if (! $loaded) { + return ''; + } + + $root = $dom->getElementById(self::ROOT_ID); + + if (! $root instanceof DOMElement) { + return ''; + } + + self::cleanChildren($root, $dom); + + $output = ''; + + foreach (iterator_to_array($root->childNodes) as $child) { + $rendered = $dom->saveHTML($child); + $output .= $rendered === false ? '' : $rendered; + } + + return $output; + } + + private static function cleanChildren(DOMNode $parent, DOMDocument $dom): void + { + foreach (iterator_to_array($parent->childNodes) as $node) { + if ($node instanceof DOMText) { + continue; + } + + if (! $node instanceof DOMElement) { + $parent->removeChild($node); + + continue; + } + + $tag = strtolower($node->tagName); + + if (! array_key_exists($tag, self::ALLOWED_TAGS)) { + $parent->removeChild($node); + + continue; + } + + self::sanitizeAttributes($node, $tag); + self::cleanChildren($node, $dom); + } + } + + private static function sanitizeAttributes(DOMElement $node, string $tag): void + { + $allowedAttributes = self::ALLOWED_TAGS[$tag]; + $attributeNames = []; + + foreach ($node->attributes ?? [] as $attribute) { + $attributeNames[] = $attribute->name; + } + + foreach ($attributeNames as $attributeName) { + $name = strtolower($attributeName); + $value = $node->getAttribute($attributeName); + + if (! in_array($name, $allowedAttributes, true)) { + $node->removeAttribute($attributeName); + + continue; + } + + if (in_array($name, ['href', 'src'], true) && ! self::isSafeUrl($value)) { + $node->removeAttribute($attributeName); + + continue; + } + + if ($name === 'align' && ! in_array(strtolower(trim($value)), self::ALLOWED_ALIGN_VALUES, true)) { + $node->removeAttribute($attributeName); + + continue; + } + + if ($name === 'class' && preg_match('/^language-[A-Za-z0-9_-]+$/', trim($value)) !== 1) { + $node->removeAttribute($attributeName); + } + } + + if ($tag === 'input') { + // Only the GFM task-list checkbox rendering is legitimate for a bare . + $node->setAttribute('type', 'checkbox'); + } + } + + private static function isSafeUrl(string $value): bool + { + // Strip control/whitespace characters browsers ignore while resolving a + // URL scheme; this defeats the classic `java\tscript:`/newline bypass. + $normalized = preg_replace('/[\x00-\x20\x7F]+/', '', $value) ?? ''; + + if ($normalized === '') { + return true; + } + + if (preg_match('/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $normalized, $matches) !== 1) { + return true; + } + + return in_array(strtolower($matches[1]), self::ALLOWED_URL_SCHEMES, true); + } +} diff --git a/backend/app/Services/Markdown/MarkdownParser.php b/backend/app/Services/Markdown/MarkdownParser.php index 5241076..c688759 100644 --- a/backend/app/Services/Markdown/MarkdownParser.php +++ b/backend/app/Services/Markdown/MarkdownParser.php @@ -13,8 +13,22 @@ class MarkdownParser { + /** + * Converter configuration hardened against script injection: raw HTML is + * escaped rather than passed through, and links/images using unsafe URL + * schemes (javascript:, data:, vbscript:, etc.) are stripped. This is the + * first sanitization layer; App\Services\Markdown\HtmlSanitizer is the + * second, tag/attribute-allowlist layer applied to the converted output. + * + * @var array + */ + private const SAFE_CONVERTER_CONFIG = [ + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + ]; + public function __construct( - private readonly GithubFlavoredMarkdownConverter $converter = new GithubFlavoredMarkdownConverter, + private readonly GithubFlavoredMarkdownConverter $converter = new GithubFlavoredMarkdownConverter(self::SAFE_CONVERTER_CONFIG), ) {} public function parse(GitHubMarkdownFile $file): ParsedDocument @@ -35,7 +49,7 @@ public function parse(GitHubMarkdownFile $file): ParsedDocument title: $title, frontmatter: $frontmatter, contentRaw: $raw, - contentHtml: (string) $this->converter->convert($body), + contentHtml: HtmlSanitizer::sanitize((string) $this->converter->convert($body)), commands: $commands, warnings: $warnings, ); diff --git a/backend/tests/Feature/MarkdownSanitizationTest.php b/backend/tests/Feature/MarkdownSanitizationTest.php new file mode 100644 index 0000000..8056d26 --- /dev/null +++ b/backend/tests/Feature/MarkdownSanitizationTest.php @@ -0,0 +1,148 @@ +" as visible "<script>" text) is not a false + * positive. + */ +function assertNoExecutableHtml(string $html): void +{ + $dom = new DOMDocument; + $previous = libxml_use_internal_errors(true); + $dom->loadHTML('
'.$html.'
', LIBXML_NOERROR | LIBXML_NOWARNING); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $dangerousTags = ['script', 'iframe', 'object', 'embed', 'style', 'form', 'svg', 'math', 'link', 'meta', 'base']; + + foreach ($dangerousTags as $tag) { + expect($dom->getElementsByTagName($tag)->length)->toBe(0); + } + + foreach ($dom->getElementsByTagName('*') as $element) { + foreach (iterator_to_array($element->attributes ?? []) as $attribute) { + $name = strtolower($attribute->name); + + expect($name)->not->toMatch('/^on/'); + + if (in_array($name, ['href', 'src'], true)) { + $normalized = strtolower(preg_replace('/[\x00-\x20\x7F]+/', '', $attribute->value) ?? ''); + + expect($normalized)->not->toMatch('/^javascript:/') + ->and($normalized)->not->toMatch('/^data:text\/html/') + ->and($normalized)->not->toMatch('/^vbscript:/'); + } + } + } +} + +it('sanitizes malicious raw HTML and links embedded in ingested Markdown while keeping legitimate rendering', function (): void { + $markdown = <<<'MD' +# Command Reference + +## /ban-player + +```yaml +syntax: /ban +description: Bans a player. +``` + +Some *legit* text with a [http link](https://example.com), a [relative link](./other.md) +and an ![image](/img/a.png "alt"). + +- [ ] pending task +- [x] done task + +| Left | Right | +|:-----|------:| +| a | b | + +```php +hi"; ?> +``` + + + + + + + + + + + + + + +
+ +[js link](javascript:alert(1)) + +[data html link](data:text/html,) + +encoded + +case variant +MD; + + $parsed = app(MarkdownParser::class)->parse(new GitHubMarkdownFile( + path: 'docs/security.md', + content: $markdown, + )); + + $html = $parsed->contentHtml; + + // Legitimate rendering is preserved. + expect($html)->toContain('

Command Reference

') + ->toContain('href="https://example.com"') + ->toContain('href="./other.md"') + ->toContain('src="/img/a.png"') + ->toContain('type="checkbox"') + ->toContain('toContain('<?php echo "<b>hi</b>"; ?>'); + + // Nothing executable survives as real markup, regardless of vector. Raw HTML + // that Markdown escaped into inert display text (e.g. "<script>") is not + // a vulnerability, so we assert against the *parsed* output rather than doing + // naive substring checks that would also flag harmless escaped text. + assertNoExecutableHtml($html); +}); + +it('serves a document whose content_html was written directly to the database, already sanitized', function (): void { + $community = Community::factory()->create(); + $plugin = Plugin::factory()->create(['community_id' => $community->id]); + $pluginVersion = PluginVersion::factory()->create(['plugin_id' => $plugin->id]); + + $document = Document::factory()->create([ + 'plugin_version_id' => $pluginVersion->id, + 'content_html' => '

Legacy Doc

' + .'' + .'' + .'click' + .'encoded', + ]); + + // The stored column keeps the untouched legacy payload: no data migration was performed. + expect($document->getRawOriginal('content_html'))->toContain('

after

')) + ->toBe('

before

after

'); +}); + +it('strips on* event handler attributes even on an otherwise allowed tag', function (): void { + expect(HtmlSanitizer::sanitize('')) + ->toBe('') + ->and(HtmlSanitizer::sanitize('

hi

')) + ->toBe('

hi

'); +}); + +it('strips an iframe tag entirely', function (): void { + expect(HtmlSanitizer::sanitize(''))->toBe(''); +}); + +it('strips an svg with an onload handler entirely', function (): void { + expect(HtmlSanitizer::sanitize(''))->toBe(''); +}); + +it('strips object and embed tags entirely', function (): void { + expect(HtmlSanitizer::sanitize(''))->toBe('') + ->and(HtmlSanitizer::sanitize(''))->toBe(''); +}); + +it('strips a style tag entirely', function (): void { + expect(HtmlSanitizer::sanitize(''))->toBe(''); +}); + +it('strips a form tag entirely', function (): void { + expect(HtmlSanitizer::sanitize('
'))->toBe(''); +}); + +it('drops javascript: link hrefs written as raw HTML', function (): void { + expect(HtmlSanitizer::sanitize('click'))->toBe('click'); +}); + +it('drops data:text/html link hrefs carrying an embedded script', function (): void { + expect(HtmlSanitizer::sanitize('click')) + ->toBe('click'); +}); + +it('drops javascript: hrefs hidden behind decimal/hex html entities', function (): void { + expect(HtmlSanitizer::sanitize('click'))->toBe('click') + ->and(HtmlSanitizer::sanitize('click'))->toBe('click'); +}); + +it('drops javascript: hrefs regardless of case', function (): void { + expect(HtmlSanitizer::sanitize('click'))->toBe('click'); +}); + +it('drops javascript: hrefs hidden behind embedded control bytes', function (): void { + expect(HtmlSanitizer::sanitize("click"))->toBe('click') + ->and(HtmlSanitizer::sanitize("click"))->toBe('click'); +}); + +it('keeps headings unchanged', function (): void { + expect(HtmlSanitizer::sanitize('

Title

Subtitle

')) + ->toBe('

Title

Subtitle

'); +}); + +it('keeps escaped code blocks safe from tag injection while preserving visible text', function (): void { + $html = '
<?php echo "hi"; ?>
'; + + // Quotes inside text content do not need re-escaping to render as literal + // quote characters; the security-relevant `<`/`>` escaping is preserved. + expect(HtmlSanitizer::sanitize($html)) + ->toBe('
<?php echo "hi"; ?>
'); +}); + +it('keeps tables with column alignment unchanged', function (): void { + $html = '' + .'
A
1
'; + + expect(HtmlSanitizer::sanitize($html))->toBe($html); +}); + +it('keeps GFM task list checkboxes unchanged', function (): void { + $html = '
  • todo
  • ' + .'
  • done
'; + + expect(HtmlSanitizer::sanitize($html))->toBe($html); +}); + +it('keeps http and https links unchanged', function (): void { + expect(HtmlSanitizer::sanitize('x')) + ->toBe('x') + ->and(HtmlSanitizer::sanitize('x')) + ->toBe('x'); +}); + +it('keeps relative links unchanged', function (): void { + expect(HtmlSanitizer::sanitize('x')) + ->toBe('x'); +}); + +it('keeps images unchanged', function (): void { + expect(HtmlSanitizer::sanitize('a')) + ->toBe('a'); +}); + +it('returns an empty string for empty or whitespace-only input', function (): void { + expect(HtmlSanitizer::sanitize(''))->toBe('') + ->and(HtmlSanitizer::sanitize(null))->toBe('') + ->and(HtmlSanitizer::sanitize(' '))->toBe(''); +}); diff --git a/brain/audits/2026-06-13-system-audit.md b/brain/audits/2026-06-13-system-audit.md index fab39ca..3af2fef 100644 --- a/brain/audits/2026-06-13-system-audit.md +++ b/brain/audits/2026-06-13-system-audit.md @@ -246,6 +246,8 @@ Fix before public deployment. Severity: Medium +Status: Mitigated in `fix/server-side-html-sanitization`. + Evidence: - Backend stores and returns `content_html` through `DocumentResource`. diff --git a/brain/canonico/CURRENT_STATE.md b/brain/canonico/CURRENT_STATE.md index c0adc13..f4dec55 100644 --- a/brain/canonico/CURRENT_STATE.md +++ b/brain/canonico/CURRENT_STATE.md @@ -25,6 +25,7 @@ The product is positioned as a documentation discovery platform for plugin ecosy - Angular SSR shell with lazy standalone routes, Signals-based UI state, Transloco i18n, command palette, protected routes, and reusable UI components. - Browser/SSR runtime configuration separates internal SSR API calls from public browser API, Reverb, allowed hosts, and canonical public origin. - Markdown viewer sanitization and heading/code enhancement. +- Server-side HTML sanitization of `content_html` in two layers: allowlist sanitizer applied at ingestion and again through a `Document` accessor on every read, so stored documents are served sanitized without rewriting the column. - SEO metadata, canonical URLs, Open Graph, JSON-LD, robots, sitemap, and portfolio screenshots, with SSR post-processing normalizing public origin metadata. - Development and production-like Docker Compose stacks. @@ -59,6 +60,7 @@ Important implementation anchors: - `App\Services\Ingestion\IngestionService` - `App\Services\Markdown\MarkdownParser` +- `App\Services\Markdown\HtmlSanitizer` - `App\Services\Discovery\DiscoveryAccess` - `App\Services\Discovery\DiscoveryCache` - `App\Contracts\GitHubClient` @@ -185,3 +187,14 @@ Latest ingestion resilience phase: - Transient failures match `>= 500` plus connection errors, not an enumerated status list. - A truncated tree and an unreadable file both end the run as `failed`; neither degrades to `partial`. - `IngestionService::fail()` signature and `IngestionRun.log` entry shape are unchanged, so downstream consumers read the taxonomy through the existing `code` key. + +Active sanitization remediation branch: `fix/server-side-html-sanitization`. + +Latest sanitization hardening phase: + +- F-009 server-side sanitization boundary (see ADR-28). +- Markdown conversion now runs with `html_input=escape` and `allow_unsafe_links=false`. +- `HtmlSanitizer` applies a tag/attribute allowlist over `DOMDocument`; `href`/`src` accept only `http`, `https`, `mailto`, and relative paths, with control bytes stripped before the scheme check. +- A `Document` accessor sanitizes `content_html` on every read, so rows stored before the fix are served sanitized without any data migration. +- Angular `MarkdownRendererService` is untouched and remains defense in depth. +- Gates: Pest `VALIDATED` (55 passed), Pint `VALIDATED` (118 files). `composer audit` reports 22 pre-existing advisories in 4 packages, and Jest has 2 pre-existing failures in `runtime-config.spec.ts`; both sets are identical to the `a3a8299` baseline and unrelated to this change. diff --git a/brain/canonico/NEXT_ACTIONS.md b/brain/canonico/NEXT_ACTIONS.md index 96532aa..4970b2f 100644 --- a/brain/canonico/NEXT_ACTIONS.md +++ b/brain/canonico/NEXT_ACTIONS.md @@ -271,7 +271,8 @@ Risks: ## Backlog - Add Content Security Policy on Laravel and SSR Node responses. -- Sanitize Markdown HTML server-side before storage or response. +- Update `league/commonmark`, `guzzlehttp/guzzle`, `guzzlehttp/psr7`, and `phpseclib/phpseclib` to clear the 22 advisories reported by `composer audit`, including CVE-2026-71478 in commonmark 2.8.2. ADR-28 already neutralizes that link-filter bypass class independently, so this is dependency hygiene, not an open XSS hole. +- Fix the two `runtime-config.spec.ts` Jest failures caused by Docker Compose environment variables leaking into the test `process.env`. - Extract optional bearer-token user resolution shared by public discovery controllers. - Keyboard-first power-user UX for command palette actions. - Saved searches and team-level curated collections. diff --git a/brain/handoffs/2026-09-20-server-side-html-sanitization.md b/brain/handoffs/2026-09-20-server-side-html-sanitization.md new file mode 100644 index 0000000..fa2dd3f --- /dev/null +++ b/brain/handoffs/2026-09-20-server-side-html-sanitization.md @@ -0,0 +1,34 @@ +# Handoff - Server-Side HTML Sanitization + +Date: 2026-09-20 +Branch: `fix/server-side-html-sanitization` + +## Scope + +Audit finding F-009: `content_html` derived from third-party Markdown was stored and returned without server-side sanitization, with the Angular renderer as the only barrier. + +## Implemented + +- `App\Services\Markdown\HtmlSanitizer`: tag/attribute allowlist over `DOMDocument`, no new dependency. `href`/`src` accept only `http`, `https`, `mailto`, and relative paths; control bytes are stripped before the scheme check; entity decoding and case folding are handled by the DOM layer. +- `MarkdownParser` converts with `html_input=escape` and `allow_unsafe_links=false`, then sanitizes before persistence. +- `Document` exposes a `contentHtml()` accessor that sanitizes on every read without rewriting the stored column, which is what covers rows ingested before the fix. +- Tests: `tests/Unit/Services/Markdown/HtmlSanitizerTest.php` (19 malicious cases plus 8 legitimate ones) and `tests/Feature/MarkdownSanitizationTest.php` (full parse pipeline, and a legacy row written straight to the database served sanitized by the API). +- See ADR-28 in `docs/DECISIONS.md`. + +## Validation + +Adversarial audit ran against an isolated `commandsphere_audit` database, which was dropped afterwards; the development database received no migrate or reset. + +- 19 payloads ingested through the real pipeline and read back through `GET /api/v1/documents/{id}`: 0 surviving executable tags, `on*` handlers, or `javascript:`/`data:` schemes. +- Second route `GET /api/v1/plugins/{slug}/versions/{version}/documents` checked against the "sanitized in one route only" failure mode: 38 documents, 0 failures. +- Legacy rows inserted through the query builder: stored column stays malicious, API output is sanitized. +- Legitimate rendering: 18/18 structure checks present, code blocks escaped rather than live tags. +- Pest 55 passed; Pint PASS on 118 files; no `skip`/`only`/`todo` in `backend/tests` or `frontend/src`. +- `composer audit` 22 advisories in 4 packages and 2 Jest failures in `runtime-config.spec.ts` are byte-identical to the `a3a8299` baseline; `composer.json`, `composer.lock`, and `frontend/` are unchanged by this branch. + +## Remaining Work + +- Deploy step, not a code defect: `DiscoveryCache` holds resolved payloads for 300 seconds, so entries cached before the deploy keep serving unsanitized HTML until they expire. Invalidate the cache on deploy, per `docs/HUMAN-ACTIONS.md`. +- Dependency advisories (`league/commonmark` 2.8.2 including CVE-2026-71478, `guzzlehttp/guzzle`, `guzzlehttp/psr7`, `phpseclib/phpseclib`) are tracked in `NEXT_ACTIONS.md`. The sanitizer neutralizes that bypass class on its own, so this is hygiene rather than an open hole. +- The `runtime-config.spec.ts` Jest failures come from Compose environment variables leaking into the test `process.env` and are unrelated to Markdown. +- Content Security Policy remains open under its own audit finding and would be the next layer above this one. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3645ab7..e671e1b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -324,3 +324,19 @@ Decisões de escopo tomadas junto: árvore truncada encerra como `failed`, e nã ### Consequências Nenhuma resposta de erro do GitHub pode mais terminar como sucesso vazio, e todo run que falha carrega um código estável em `IngestionRun.log`, na chave `code` que já existia. A assinatura de `IngestionService::fail()` e a estrutura das entradas de `log` não mudaram, então consumidores a jusante — telemetria e correlação de eventos — leem a taxonomia sem migração. O teste de transitório usa `>= 500` em vez de lista de status, de modo que qualquer 5xx futuro é coberto sem alteração. O caminho 304/ETag do ADR-11 retorna antes de qualquer verificação de erro e segue inalterado: o run permanece `success` e registra `document_not_modified`. O trade-off é rigidez deliberada — uma indisponibilidade momentânea do GitHub agora reprova o run inteiro em vez de ingerir o que deu, e é exatamente por isso que a ação de retry da Prioridade 8 se torna mais necessária. Mensagens de exceção carregam apenas repositório, caminho e status; nunca o token, o cabeçalho `Authorization` ou o corpo da resposta. + +## ADR-28 — Sanitização de HTML no servidor em duas camadas + +### Contexto +`content_html` é derivado de Markdown de terceiros vindo do GitHub. Até então a conversão usava `GithubFlavoredMarkdownConverter` sem configuração, o que mantém os padrões `html_input=allow` e links inseguros permitidos, e a sanitização existia apenas no Angular (`MarkdownRendererService`, ADR-18). Qualquer outro consumidor da API — export, integração, client alternativo, view administrativa — recebia HTML executável. Documentos ingeridos antes da correção já estavam gravados com esse HTML. + +### Decisão +Sanitizar no backend em duas camadas, com um allowlist próprio de tags e atributos sobre `DOMDocument` (`App\Services\Markdown\HtmlSanitizer`), sem dependência nova: + +1. Na ingestão, o conversor passa a usar `html_input=escape` e `allow_unsafe_links=false`, e a saída é sanitizada antes de persistir. +2. Na leitura, um accessor no model `Document` sanitiza `content_html` a cada acesso, sem reescrever a coluna. + +`href`/`src` aceitam apenas `http`, `https`, `mailto` e caminhos relativos, com remoção de bytes de controle antes da checagem de esquema. A sanitização do Angular permanece como defesa em profundidade. + +### Consequências +Documentos antigos ficam cobertos sem migração nem reprocessamento: a coluna armazenada permanece como está e a sanitização acontece na saída, o que satisfaz a lei DATA sem escrita em dado existente. A proteção não depende do filtro interno do `league/commonmark`, o que neutraliza a classe de bypass por bytes de controle do `CVE-2026-71478` (versão instalada 2.8.2) independentemente da atualização daquele pacote — complementar ao ADR-26, que trata a política de advisories por override de dependência. O custo é sanitizar a cada leitura; a operação é idempotente e o resultado de discovery já é cacheado por `DiscoveryCache`. Entradas cacheadas antes do deploy continuam cruas até expirar (300s), então o deploy desta mudança exige invalidar o cache de discovery. diff --git a/docs/HUMAN-ACTIONS.md b/docs/HUMAN-ACTIONS.md index e642ee9..3d6967e 100644 --- a/docs/HUMAN-ACTIONS.md +++ b/docs/HUMAN-ACTIONS.md @@ -11,3 +11,15 @@ DISCORD_REDIRECT_URI="${APP_URL}/api/v1/auth/discord/callback" ``` For local development and CI, `/api/v1/auth/dev-login` remains available only when `APP_ENV` is `local` or `testing`, so tests and backend work do not depend on external Discord credentials. + +## Invalidate the discovery cache when deploying ADR-28 + +`App\Services\Discovery\DiscoveryCache` stores already-resolved discovery payloads for 300 seconds, and `CatalogController` serves `document` and `versionDocuments` through it. Entries cached before the server-side sanitization deploy keep returning unsanitized `content_html` until they expire. + +After deploying the sanitization change, invalidate the discovery cache instead of waiting out the window: + +```bash +docker compose exec -T backend php artisan tinker --execute="app(App\Services\Discovery\DiscoveryCache::class)->invalidate();" +``` + +This is a one-time deploy step. New responses are sanitized on read, so the cache repopulates with sanitized payloads.