From c425eb23eb5b09cbd3eaeef4e13557a3606d0cff Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 29 Aug 2026 02:07:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(module):=20Tiger=5FModule=5FLongform=20?= =?UTF-8?q?=E2=80=94=20one=20renderer=20for=20a=20listing's=20long-form=20?= =?UTF-8?q?copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces show the same "plugin page" body — the Module Manager's "View more" and a marketplace's own listing page — and they rendered it two different ways, with two different safety policies. A listing could look safe in one and not the other. Tiger_Module_Longform is now the single component behind both. It resolves the copy from where it actually lives — inline `readme` on the listing (a paid/PASS module's repo is PRIVATE, so its marketplace serves the review copy in the feed; `body` kept as an alias) or a public repo's `tiger_md` URL, https-only, size-capped and disk-cached — and renders it once, safely. The safety change is the point. Both call sites previously ran `Tiger_Cms_Renderer::renderBody($md, 'markdown')` and then regex-stripped active content from the RESULT. That path renders through the shared Parsedown::instance() with markup ALLOWED and then runs the [shortcode] pass — correct for a trusted CMS author, wrong for a file written by whoever published the module, since it emits their markup and lets them invoke this install's shortcodes. The scrub-afterwards regex was the only thing standing between the two. Longform instead escapes inline HTML at PARSE time (safe-mode Parsedown, its own instance so the CMS singleton is untouched) and filters dangerous URL schemes. Escaping at parse time is strictly stronger than stripping tags afterwards, which is the classic thing to get subtly wrong. `_scrub()` is removed with its last caller. Also fixes a contract that had never fired: _inspectMarketplace() reads `$listing['readme']`, but no producer emitted that field, so a paid listing's "View more" only ever showed its one-line description. TigerMarketplace's module adapter now declares it, so the feed carries it and the Module Manager renders the seller's real copy. 15 unit tests, the load-bearing ones being safety: raw HTML is escaped rather than emitted, an event-handler attribute cannot survive as an attribute, a javascript: link is filtered, and an http:// body is refused WITHOUT being fetched. Full suite green (2111 tests). --- CAPABILITIES.md | 3 +- MARKETPLACE.md | 1 + library/Tiger/Module/Longform.php | 198 ++++++++++++++++++++++++++++ modules/system/services/Modules.php | 25 +--- tests/Unit/Module/LongformTest.php | 166 +++++++++++++++++++++++ 5 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 library/Tiger/Module/Longform.php create mode 100644 tests/Unit/Module/LongformTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 484f5670..217d6990 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**194 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**195 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -124,6 +124,7 @@ - **Tiger_Module_Discovery** `@api` — find the modules present on disk (active or not). · `library/Tiger/Module/Discovery.php` - **Tiger_Module_Github** `@api` — read public GitHub repos over cURL (no auth, public only). · `library/Tiger/Module/Github.php` - **Tiger_Module_Installer** `@api` — install / update / remove modules from public GitHub repos. · `library/Tiger/Module/Installer.php` +- **Tiger_Module_Longform** `@api` — resolves a module listing's LONG-FORM copy and renders it safely. · `library/Tiger/Module/Longform.php` - **Tiger_Module_Pricing** `@api` — the manifest `pricing` block, normalized. · `library/Tiger/Module/Pricing.php` - **Tiger_Module_Registry** `@api` — the client for the module catalog, now **multi-source**. · `library/Tiger/Module/Registry.php` - **Tiger_Module_Source** `@api` — one catalog feed the Module Manager reads. · `library/Tiger/Module/Source.php` diff --git a/MARKETPLACE.md b/MARKETPLACE.md index e6d58025..14b2ce78 100644 --- a/MARKETPLACE.md +++ b/MARKETPLACE.md @@ -162,6 +162,7 @@ The whole client half is core, free, and **vendor-neutral** — it works against | `Tiger_Crypto_Signature` | Ed25519 keypair / sign / verify / `verifyFile` / `fingerprint` | | `Tiger_License_Checker` | hold the install's license keys, **verify** against a module's declared authority (cached, signed), **`gate()`** auto-update, `remember()` a bought license. Persists in the lazy `option` tier (`Tiger_License_Store`). | | `Tiger_License_Authority` (client) | the client for an authority's `/download` endpoint — get a signed download descriptor `{url, signature, sha256, version}` | +| `Tiger_Module_Longform` | resolve a listing's long-form "plugin page" copy — inline `readme` (a paid module's repo is private, so its marketplace serves the review copy) or a public repo's `tiger_md` URL — and render it through a **safe-mode** markdown parser. The ONE renderer behind both the Module Manager's "View more" and a marketplace's own listing page. | | `Tiger_Module_Installer::installFromAuthority` | fetch the signed download → **verify the signature before extract** → install → `remember()` the license | | `Tiger_Update_Checker` + `System_Service_Updates` | annotate a licensed module's update with its license state, and **refuse applying an update** to a definitively lapsed one | diff --git a/library/Tiger/Module/Longform.php b/library/Tiger/Module/Longform.php new file mode 100644 index 00000000..16e30cdf --- /dev/null +++ b/library/Tiger/Module/Longform.php @@ -0,0 +1,198 @@ +setSafeMode(true); // filters javascript:/data: URLs and unsafe attributes + $parser->setMarkupEscaped(true); // raw HTML in the source is SHOWN, never executed + $parser->setBreaksEnabled(false); + return (string) $parser->text($markdown); + } catch (Throwable $e) { + return ''; + } + } + + /** + * Fetch a `tiger_md` URL, cached on disk. + * + * HTTPS only: a body served over plain http can be rewritten in transit into whatever an attacker + * wants an admin to read about a module they are deciding whether to install. + * + * @param string $url the raw markdown URL + * @return string the markdown ('' on any failure) + */ + public static function fetch($url) + { + $url = (string) $url; + if (stripos($url, 'https://') !== 0) { return ''; } + + // An injected transport replaces the WHOLE fetch path, disk cache included — consulting the + // cache first would make a test depend on whether an earlier run had warmed that URL's file. + if (self::$_transport !== null) { + $body = call_user_func(self::$_transport, $url); + return self::_acceptable($body) ? $body : ''; + } + + $cached = self::_cacheGet($url); + if ($cached !== null) { return $cached; } + + $context = stream_context_create(['http' => [ + 'timeout' => self::TIMEOUT, + // An outbound request with no User-Agent is 403'd by some WAFs (file_get_contents sends + // none by default), which would read as "the vendor is down" for every listing behind one. + 'header' => "User-Agent: Tiger/" . Tiger_Version::VERSION . "\r\n", + 'follow_location' => 1, + 'max_redirects' => 3, + ]]); + $body = @file_get_contents($url, false, $context, 0, self::MAX_BYTES + 1); + + if (!self::_acceptable($body)) { return ''; } + + self::_cachePut($url, $body); + return $body; + } + + /** A fetched body is usable when it is a non-empty string within the size ceiling. */ + protected static function _acceptable($body) + { + return is_string($body) && $body !== '' && strlen($body) <= self::MAX_BYTES; + } + + /** The cached markdown for a URL while it is still fresh, else null. */ + protected static function _cacheGet($url) + { + $file = self::_cacheFile($url); + if ($file && is_file($file) && (time() - filemtime($file)) < self::CACHE_TTL) { + $body = @file_get_contents($file); + if (is_string($body)) { return $body; } + } + return null; + } + + /** Store a fetched body; a failed write just means the next request refetches. */ + protected static function _cachePut($url, $body) + { + $file = self::_cacheFile($url); + if ($file) { @file_put_contents($file, $body); } + } + + /** The on-disk cache path for a URL (hashed — a URL is not a safe filename). */ + protected static function _cacheFile($url) + { + $base = defined('APPLICATION_ROOT') ? rtrim(APPLICATION_ROOT, '/') : rtrim(getcwd(), '/'); + $dir = $base . '/var/cache/longform'; + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return null; } + return $dir . '/' . sha1($url) . '.md'; + } +} diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index 6e30816f..65212b39 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -671,10 +671,7 @@ public function inspect(array $params): void } $tigerMd = Tiger_Module_Github::fetchRaw($r['org'], $r['repo'], $ref, 'TIGER.md'); - $descHtml = ''; - if ($tigerMd !== null) { - try { $descHtml = $this->_scrub((new Tiger_Cms_Renderer())->renderBody($tigerMd, 'markdown')); } catch (Throwable $e) {} - } + $descHtml = $tigerMd !== null ? Tiger_Module_Longform::render($tigerMd) : ''; // "Installed" = recorded by the installer OR simply present on disk (discovered) — the // latter covers a theme/module placed manually or activated without an installer row. @@ -721,11 +718,10 @@ protected function _inspectMarketplace(string $slug, string $source): void $listing = Tiger_Module_Registry::listing($slug, $source); if (!$listing) { $this->_error('system.error.listing_gone'); return; } - $descHtml = ''; - $readme = (string) ($listing['readme'] ?? ''); - if ($readme !== '') { - try { $descHtml = $this->_scrub((new Tiger_Cms_Renderer())->renderBody($readme, 'markdown')); } catch (Throwable $e) {} - } + // Resolved through the shared component: `readme` inline (a paid module's repo is private, so + // its marketplace serves the copy) or a `tiger_md` URL — the SAME resolution and the SAME safe + // render a marketplace's own listing page uses, so one listing can never look different here. + $descHtml = Tiger_Module_Longform::html($listing); $row = (new Tiger_Model_Module())->bySlug($slug); $discovered = Tiger_Module_Discovery::all(); @@ -753,17 +749,6 @@ protected function _inspectMarketplace(string $slug, string $source): void ]); } - /** Strip active content from untrusted vendor markdown (the TIGER.md preview). */ - protected function _scrub($html) - { - $html = (string) $html; - $html = preg_replace('#<(script|style|iframe|object|embed)\b[^>]*>.*?#is', '', $html); - $html = preg_replace('#<(script|style|iframe|object|embed|link|meta|base)\b[^>]*>#is', '', $html); - $html = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html); - $html = preg_replace('#(href|src)\s*=\s*(["\']?)\s*javascript:[^"\'>]*\2#i', '$1=$2#$2', $html); - return $html; - } - /** * Install (or update, with force) a module from a public GitHub URL. * diff --git a/tests/Unit/Module/LongformTest.php b/tests/Unit/Module/LongformTest.php new file mode 100644 index 00000000..562fd2a5 --- /dev/null +++ b/tests/Unit/Module/LongformTest.php @@ -0,0 +1,166 @@ +assertSame('# hello', Tiger_Module_Longform::markdown(['readme' => '# hello'])); + } + + /** `body` predates the shared name; a listing authored against it must keep rendering. */ + #[Test] + public function bodyIsAcceptedAsAnAlias(): void + { + $this->assertSame('# hello', Tiger_Module_Longform::markdown(['body' => '# hello'])); + } + + #[Test] + public function readmeWinsOverBody(): void + { + $this->assertSame('# a', Tiger_Module_Longform::markdown(['readme' => '# a', 'body' => '# b'])); + } + + /** A private (paid) module carries its copy inline; the URL is only for public repos. */ + #[Test] + public function inlineCopyWinsOverAFetchedUrl(): void + { + Tiger_Module_Longform::setTransport(static fn ($url) => '# fetched'); + + $this->assertSame('# inline', Tiger_Module_Longform::markdown([ + 'readme' => '# inline', + 'tiger_md' => 'https://example.test/TIGER.md', + ])); + } + + #[Test] + public function fetchesTigerMdWhenThereIsNoInlineCopy(): void + { + $seen = null; + Tiger_Module_Longform::setTransport(function ($url) use (&$seen) { + $seen = $url; + return "# Hello\n\nA paragraph."; + }); + + $md = Tiger_Module_Longform::markdown(['tiger_md' => 'https://example.test/a/TIGER.md']); + + $this->assertSame('https://example.test/a/TIGER.md', $seen); + $this->assertStringContainsString('# Hello', $md); + } + + #[Test] + public function aListingWithNeitherYieldsNothing(): void + { + $this->assertSame('', Tiger_Module_Longform::markdown([])); + $this->assertSame('', Tiger_Module_Longform::html([])); + } + + /** A body served over plain http can be rewritten in transit — refuse it before fetching. */ + #[Test] + public function refusesANonHttpsUrl(): void + { + $called = false; + Tiger_Module_Longform::setTransport(function () use (&$called) { $called = true; return '# nope'; }); + + $this->assertSame('', Tiger_Module_Longform::markdown(['tiger_md' => 'http://example.test/TIGER.md'])); + $this->assertFalse($called, 'an http:// body must never even be fetched'); + } + + #[Test] + public function rendersMarkdownToHtml(): void + { + $html = Tiger_Module_Longform::render("# Title\n\nSome **bold** copy."); + + $this->assertStringContainsString('

Title

', $html); + $this->assertStringContainsString('bold', $html); + } + + /** Safe mode: a third party's markdown may not inject markup into the admin screen. */ + #[Test] + public function escapesRawHtmlInUntrustedMarkdown(): void + { + $html = Tiger_Module_Longform::render('Hi there'); + + $this->assertStringNotContainsString('