Skip to content

Do not block special pages transcluded into wiki pages - #89

Open
kghbln wants to merge 2 commits into
mywikis:mainfrom
kghbln:allow-transcluded-special-pages
Open

kghbln wants to merge 2 commits into
mywikis:mainfrom
kghbln:allow-transcluded-special-pages

Conversation

@kghbln

@kghbln kghbln commented Nov 3, 2026 •

Copy link
Copy Markdown

Adds $wgCrawlerProtectionAllowTranscludedSpecialPages (default false). When true, special pages transcluded into a wiki page, such as {{Special:RecentChanges/10}}, are not checked. Without it, a wiki page that transcludes a protected special page shows the denial instead: to anonymous viewers, and in miser mode, where MediaWiki runs a transcluded special page as an anonymous user, to every viewer; with $wgCrawlerProtectionRawDenial the denial replaces the whole page. The option lets requests that render client-supplied wikitext, such as action=parse with text, run a protected special page, as the README spells out, so it is off by default and the version becomes 1.8.0.

Human input

Goal and shape: goal from @kghbln, to fix a wiki whose pages went blank this way; @kghbln chose to fix it here rather than with a CrawlerProtectionShouldDeny handler on that wiki. After the review, @kghbln made the exemption an option, off by default. The technical design within that is Claude's.

Outcome: not reviewed by @kghbln yet. Left for you: the change and the README wording.

Claude's calls

  • Not discussed with @kghbln: the option's name; the exemption stays an early return in Hooks::onSpecialPageBeforeExecute, which asks the new CrawlerProtectionService::allowsTranscludedSpecialPages(); CrawlerProtectionShouldDeny does not run for a transcluded special page while the option is on, documented on the hook interface and in the README, since running it with $shouldDeny = false would let a handler deny in the middle of rendering again, deciding on a stand-in anonymous user; the README names the REST transform endpoint rather than a pattern.
  • Considered by Claude, omitted: telling transclusions apart by the request class (FauxRequest), which including() states directly.
  • Not verified: Phan; MediaWiki 1.39, 1.44 and 1.45, and PHP other than 8.3, which CI covers.
  • Where your judgment helps: on MediaWiki 1.44 and later, core's router appears to pass /v1/… paths from coreRoutes.json to RestCheckCanExecute unchanged, as no module named v1 is registered, which would keep the README's example /page/*/history from matching. Read from core's code only and not touched here; details in the README thread.

The change

  • extension.json: $wgCrawlerProtectionAllowTranscludedSpecialPages, default false; version 1.8.0.
  • includes/CrawlerProtectionService.php: allowsTranscludedSpecialPages().
  • includes/Hooks.php: onSpecialPageBeforeExecute returns true for a transcluded special page while the option is on.
  • README.md and includes/Hook/CrawlerProtectionShouldDenyHook.php: the option, the requests it opens up and how to close them, and the hook not running for transcluded special pages.
  • Tests: HooksTest covers both settings; CrawlerProtectionIntegrationTest renders {{Special:WhatLinksHere/Test}} with and without the option.
  • Suggested release note: New $wgCrawlerProtectionAllowTranscludedSpecialPages: set it to true when wiki pages transclude a protected special page, which otherwise shows the denial; the README lists the requests it opens up.
Verification evidence
  • PHPUnit on MediaWiki REL1_43 (926952c), PHP 8.3.6 and SQLite, set up like the CI job: 181 tests pass, unit and integration (178 before).
  • The default-case integration test failed on the first commit, which exempted every transcluded special page: the denial text was missing. With the exemption removed from Hooks.php, the option-on test fails: the special page's list is missing.
  • phpcs with the repo's .phpcs.xml on the six changed PHP files: clean, with mediawiki-codesniffer 45.0.0 as resolved in the MediaWiki tree (the repo pins 43.0.0).
  • .github/scripts/check-version-bump.sh upstream/main HEAD: "Version bump 1.7.0 -> 1.8.0 is valid."
  • From the first commit: reproduced on a MediaWiki 1.43.11 wiki in miser mode with recentchanges and allpages protected and raw denial on: anonymous api.php?action=parse&text={{Special:Recentchanges/10}} and {{Special:AllPages/Widget:}} answer HTTP 403 with the raw denial text. Parser::braceSubstitution (REL1_43) runs a transcluded special page as User::newFromName( '127.0.0.1', false ) unless maxIncludeCacheTime() is 0, which SpecialPage returns only outside miser mode. SpecialPageFactory::executePath sets including() before run() on REL1_39 and REL1_43.

AI-authored — Claude Code, Opus 5.5 (max), on a Professional Wiki account

A wiki page that transcludes a protected special page, such as
{{Special:RecentChanges/10}}, was answered with the denial for every
viewer: in miser mode MediaWiki runs a transcluded special page as an
anonymous user whoever views the page, and with
$wgCrawlerProtectionRawDenial the denial replaces the whole page.

A transcluded special page runs with the arguments the wiki page gives
it, not with the visitor's request, so it is no longer checked. The
README names the requests that render client-supplied wikitext, such
as action=parse with text, and how to protect them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_e56c0dc3-48ae-44a3-accc-c8db51e0fcb5

@jeffw16 jeffw16 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct and in the right place. When the parser renders a transcluded special page, it builds a new request from the wiki page's arguments, with no real client IP (it reads as 127.0.0.1). For pages whose output can be cached (by default, in miser mode), it also runs the page as an anonymous user. So the check always failed, even for logged-in visitors and allowlisted IPs. With $wgCrawlerProtectionRawDenial, denyAccessRaw() then calls die() in the middle of rendering and kills the whole page view. With the "pretty" denial page, the "access denied" text ends up in the page body and can be stored in the parser cache. including() has existed far longer than REL1_39, so there's no compatibility risk, and CI is green across the matrix.

A few points before merging:

1. This loosens the default protection. Two of the three special pages protected by default, whatlinkshere and recentchangeslinked, can be transcluded. Before this PR, an anonymous request such as api.php?action=parse&text={{Special:WhatLinksHere/Foo|limit=500}} was denied, although only as a side effect of the bug. Now it goes through unless the operator adds parse to $wgCrawlerProtectedApiModules, which is empty by default. The README discloses this, but a default install is now more open than in 1.7.0. I'd call this out in the release notes and consider bumping to 1.8.0 rather than 1.7.1. Protecting parse by default isn't a good answer either, because it would break anonymous previews.

2. The README list of bypass paths is incomplete. Besides action=parse and Special:ExpandTemplates, anonymous edit previews, the REST /transform/wikitext/to/html endpoint, and extensions such as TemplateSandbox can also render client-supplied wikitext. I've left an inline suggestion that rewords the paragraph for clarity and covers these.

3. The CrawlerProtectionShouldDeny hook no longer fires for transclusions. Code that relies on that hook can't see or overrule this bypass. That's probably acceptable, but it should be documented on the hook. Alternatively, the bypass could go through the hook with $shouldDeny = false.

4. Tests (optional). The unit test covers the hook logic. An integration test that renders a page containing {{Special:WhatLinksHere/...}} and checks it isn't denied would catch future changes in how core builds the transclusion's request.

There's also a small wording fix inline for the doc comment in Hooks.php.


Generated by Claude Code

Comment thread README.md Resolved
Comment thread includes/Hooks.php Resolved
Skipping the check for transcluded special pages also lets every
request that renders wikitext supplied by the client, such as
action=parse with text, run a protected special page, which 1.7.0
denied. The skip therefore only applies with
$wgCrawlerProtectionAllowTranscludedSpecialPages set to true, so a
default install keeps the behaviour of 1.7.0, and the version becomes
1.8.0.

The README documents the option, lists more requests that render
client-supplied wikitext with how to protect them, and states that
CrawlerProtectionShouldDeny does not run for transcluded special pages
while the option is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0af9e55d-60c0-4046-b4d4-c9f357d779c4
@kghbln

kghbln commented Nov 5, 2026

Copy link
Copy Markdown
Author

Thanks for the careful review. 65a7734 addresses it:

  1. The exemption is now opt-in through $wgCrawlerProtectionAllowTranscludedSpecialPages, default false, so a default install behaves like 1.7.0, including the denial of action=parse with text. Wikis that transclude a protected special page turn it on. The version is 1.8.0, and the PR description carries a suggested release note.
  2. Your README rewording is in, now under the option; the REST pattern is answered in the README thread.
  3. Documented on the hook interface and in the README. Running the hook for transclusions with $shouldDeny = false would let a handler deny in the middle of rendering again, deciding on a stand-in anonymous user and a request that is not the visitor's, so it stays out.
  4. Added: two integration tests render {{Special:WhatLinksHere/Test}}, denied by default and listed with the option on.

AI-authored — Claude Code, Opus 5.5 (max), on a Professional Wiki account; making the exemption opt-in is @kghbln's call, the rest Claude's reasoning and wording; not read by a human before posting; the tests ran on MediaWiki 1.43 with PHP 8.3, 181 passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants