Skip to content

Address review findings on 1.7.0: denial headers, API status code, hook entry points - #66

Merged
jeffw16 merged 1 commit into
candidate/1.7.0from
copilot/address-comments-from-cicalese
Jul 30, 2026
Merged

Address review findings on 1.7.0: denial headers, API status code, hook entry points#66
jeffw16 merged 1 commit into
candidate/1.7.0from
copilot/address-comments-from-cicalese

Conversation

Copilot AI commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Follow-up to @cicalese's review of #64. The findings were: denial responses were only marked noindex on the HTML path, Action API denials returned 200 OK, and CrawlerProtectionShouldDeny was shipping as @stable to implement with an index.php-only signature.

Denial responses

  • denyAccessRaw() now sends X-Robots-Tag: noindex,nofollow, covering the raw-403 and 418 strategies — the ones operators actually enable under crawler load.
  • New ResponseFactory::markDenied( $response, ?int $statusCode ) marks denials that aren't rendered through OutputPage. Core answers an ApiCheckCanExecute veto with dieWithError() and no HTTP code, so ApiMain::handleException() reads getCode() === 0 and emits 200 OK; checkApiModules() now sets 403 explicitly. REST keeps its 403 from LocalizedHttpException and only gains the robot directive.

CrawlerProtectionShouldDeny

Added a string $entryPoint argument (constants ENTRY_POINT_INDEX / ENTRY_POINT_API / ENTRY_POINT_REST) before the interface stabilizes, and wired the hook into the API and REST paths rather than deferring them. $specialPageName is always null for api/rest; $request is documented nullable.

$wgHooks['CrawlerProtectionShouldDeny'][] = static function (
	$user, $request, $entryPoint, $specialPageName, &$shouldDeny
) {
	if ( $entryPoint === 'api' && $request && $request->getHeader( 'X-My-Crawler-Token' ) === $secret ) {
		$shouldDeny = false;
	}
};

Docs

  • README no longer implies the allowlist change fixes reverse-proxy resolution; it states that WebRequest::getIP() behaves as it does everywhere in MediaWiki, including reporting the proxy address when the proxy is unregistered.
  • The $wgCrawlerProtectionTrustXForwardedFor warning now requires a proxy that unconditionally sets or appends the header, names HAProxy's option forwardfor if-none as the gap, and gives correct HAProxy/nginx directives.
  • The PR description's two claims about the allowlist "bug fix" still need rewording; suggested text is in the review reply.

Tests

  • Integration: ApiCheckCanExecute / RestCheckCanExecute registration, a reflection check of handler signatures against core's hook interfaces (the class deliberately doesn't implement them, so PHP can't catch drift), and end-to-end API/REST service coverage. A real ApiMain::execute() isn't viable — PHPUnit runs as CLI, where the container service is built with cliMode = true.
  • Unit: hook entry-point propagation, markDenied() behavior, and assertNotSame( '', … ) alongside the previously tautological i18n fallback assertions.

Smaller items

  • fnmatch() guarded with function_exists(); absent, no REST path is treated as protected and a warning is logged.
  • normalizeArrayConfig() no longer warns twice for a single non-string scalar.
  • check-i18n-qqq.sh rewritten in PHP (drops the Python dependency) and now also fails on qqq keys orphaned from en.json.
  • composer phpunit scoped to tests/phpunit/unit/, matching the documented behavior; added composer phpunit:integration. The CI job still runs the full directory.

…ocs and tests

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>
@jeffw16
jeffw16 marked this pull request as ready for review July 30, 2026 20:16
Copilot AI review requested due to automatic review settings July 30, 2026 20:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR is a follow-up to prior review feedback, tightening denial behavior across all entry points (index.php/API/REST) by ensuring crawler directives are consistently applied, Action API denials use an explicit 403, and the CrawlerProtectionShouldDeny hook is extended to cover API/REST with an entry-point identifier.

Changes:

  • Add ResponseFactory::markDenied() and use it from API/REST denial paths to send X-Robots-Tag: noindex,nofollow, plus an explicit 403 for Action API denials.
  • Extend CrawlerProtectionShouldDeny with an $entryPoint argument (constants for index/api/rest) and run it for API and REST paths.
  • Expand unit/integration tests and update docs/tooling (including phpunit script scoping and the i18n qqq checker rewrite).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
includes/ResponseFactory.php Adds consistent robot directives for non-OutputPage denials via markDenied() and extends raw denial headers.
includes/CrawlerProtectionService.php Runs the should-deny hook for API/REST entry points and marks API/REST denials with robots header (and 403 for API).
includes/HookRunner.php Updates hook runner signature to pass the new $entryPoint argument.
includes/Hook/CrawlerProtectionShouldDenyHook.php Extends the hook contract with entry-point constants and a new $entryPoint parameter.
tests/phpunit/unit/ResponseFactoryTest.php Adds unit coverage for markDenied() and strengthens raw-denial fallback assertions.
tests/phpunit/unit/CrawlerProtectionServiceTest.php Updates hook-handler signatures and adds unit tests for API/REST hook propagation and denial header marking.
tests/phpunit/namespaced-stubs.php Extends the WebResponse stub with statusHeader() for tests.
tests/phpunit/integration/CrawlerProtectionIntegrationTest.php Adds integration coverage for API/REST hook registration and basic end-to-end API/REST denial behavior.
README.md Updates denial/header behavior documentation and clarifies reverse-proxy/XFF trust guidance; documents new hook signature.
TESTING.md Documents separate unit vs integration test commands.
composer.json Scopes composer phpunit to unit tests and adds phpunit:integration.
.github/scripts/check-i18n-qqq.sh Rewrites the i18n qqq checker in PHP and adds orphaned-key detection.

Comment thread .github/scripts/check-i18n-qqq.sh

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

includes/Hook/CrawlerProtectionShouldDenyHook.php:67

  • The hook docblock currently claims $user/$request are always the namespaced classes (\MediaWiki\User\User, \MediaWiki\Request\WebRequest). Across supported MediaWiki versions these can be the legacy global classes (\User, \WebRequest), so the doc can mislead hook implementers into adding incompatible type hints. Document both possible types to reflect actual runtime values.
	 * @param \MediaWiki\User\User $user The user making the request
	 * @param \MediaWiki\Request\WebRequest|null $request The current request,
	 *   or null when the entry point cannot supply one

includes/CrawlerProtectionService.php:440

  • When fnmatch() is unavailable, this warning will be emitted on every call to isProtectedRestPath(). Under crawler load that can create very high log volume, even though the message is static. Consider emitting it only once (per PHP process) to avoid flooding logs while still surfacing the configuration problem.
		if ( !function_exists( 'fnmatch' ) ) {
			$this->logger->warning(
				'CrawlerProtection: fnmatch() is unavailable, so ' .
					'CrawlerProtectedRestPaths cannot be evaluated.'
			);

@jeffw16
jeffw16 merged commit 78ad668 into candidate/1.7.0 Jul 30, 2026
35 checks passed
@jeffw16
jeffw16 deleted the copilot/address-comments-from-cicalese branch July 30, 2026 21:01
@cicalese cicalese mentioned this pull request Jul 30, 2026
jeffw16 added a commit that referenced this pull request Jul 31, 2026
* i18n: add qqq.json, descriptionmsg, localised raw-denial responses, and CI qqq completeness check (#55)

* Initial plan

* Add i18n improvements: qqq.json, descriptionmsg, localised raw-denial, CI check

* tests: add unit tests for i18n fallback in denyAccess and denyAccessWith418

* tests: refactor i18n fallback tests to AAA format with explicit assertions

* fix: use ->plain() instead of ->text() to resolve SecurityCheck-XSS phan errors

* fix: suppress SecurityCheck-XSS for trusted message bodies in raw denial

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* Protect api.php and rest.php entry points from crawlers (#56)

* Initial plan

* feat: protect api.php (ApiCheckCanExecute) and rest.php (RestCheckCanExecute) entry points

- Add CrawlerProtectedApiModules config (default []) for Action API module protection
- Add CrawlerProtectedRestPaths config (default []) for REST API path protection via fnmatch glob patterns
- Register ApiCheckCanExecute and RestCheckCanExecute hooks in extension.json
- Restructure Hooks.php with namespace-block compat stubs so the class implements both new hook interfaces on all supported MW versions; REST protection is silently skipped on MW < 1.42 where RestCheckCanExecute does not fire
- Add checkApiModule, isProtectedApiModule, checkRestPath, isProtectedRestPath to CrawlerProtectionService
- Add stub interfaces for ApiCheckCanExecuteHook, RestCheckCanExecuteHook, HttpException in namespaced-stubs.php
- Add unit tests for all new service methods and hook handlers
- Update README with entry-point coverage table and new config documentation

Closes #48

* Operationalize Copilot guidance with repo-specific rules, correct scope, and agent setup workflow (#61)

* Initial plan

* Overhaul Copilot instructions with repo-specific CI architecture and testing guidance

* Clarify trimmed guidance and Copilot setup workflow intent

* Restore comprehensive MediaWiki instruction guidance

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: use FNM_PATHNAME for REST path globs, fix docblock, bump version

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* fix: drop core hook interface stubs and use LocalizedHttpException for REST denial

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* ci: pin actions to SHAs, extend matrix to MW 1.44/1.45, add Dependabot, enforce minus-x (#59)

* Initial plan

* ci: hygiene improvements - pin actions, extend matrix, add dependabot

* ci: skip minus-x on REL1_39 where MediaWiki does not ship it

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Bump actions/checkout from 4.1.7 to 7.0.1 (#65)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.7 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](actions/checkout@v4.1.7...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Merge main; cover query sub-modules and correct REST hook MW version

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* test: extract REST handler stub helper with property docs (MW 1.45 phpcs)

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix IP allowlist to use WebRequest::getIP(); add temp-account policy; normalize array configs (#57)

* Initial plan

* Fix IP allowlist to use WebRequest::getIP(), add TreatTempUsersAsAnon config, normalize array configs

- CrawlerProtectionService: derive client IP from $request->getIP() (not
  $user->getName()) in both checkPerformAction() and checkSpecialPage()
- Add $request parameter to checkSpecialPage(); update Hooks.php to pass
  $special->getContext()->getRequest()
- Add CrawlerProtectionTreatTempUsersAsAnon config (default false) and
  isUserAllowed() helper; temp-account users (isRegistered+isTemp) are
  treated as anonymous when the flag is true
- Pre-normalise all array-valued configs at construction time: coerce
  scalars to single-element arrays, drop non-string entries, log warnings
  (prevents fatal on misconfigured scalar values)
- Validate CrawlerProtectionAllowedIPs entries at construction time and
  log a warning for invalid IP/range strings
- Inject LoggerInterface via constructor and ServiceWiring.php
- Add CrawlerProtectionTreatTempUsersAsAnon to extension.json
- Update tests: fix IP tests to use request->getIP(), add $request param
  to checkSpecialPage() calls, add tests for temp accounts, IP-from-
  request, and scalar misconfiguration
- Add getIP() to WebRequest stub for unit tests
- Document new config options in README.md

* Operationalize Copilot guidance with repo-specific rules, correct scope, and agent setup workflow (#61)

* Initial plan

* Overhaul Copilot instructions with repo-specific CI architecture and testing guidance

* Clarify trimmed guidance and Copilot setup workflow intent

* Restore comprehensive MediaWiki instruction guidance

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* Validate IP allowlist entries with IPUtils only; make temp-user mocks version-safe

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* ci: pin actions to SHAs, extend matrix to MW 1.44/1.45, add Dependabot, enforce minus-x (#59)

* Initial plan

* ci: hygiene improvements - pin actions, extend matrix, add dependabot

* ci: skip minus-x on REL1_39 where MediaWiki does not ship it

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* tests: give the Hooks mock context a getRequest() so special-page tests pass

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>
Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>

* test: add integration tests and fix denyAccessPretty branch testability (#58)

* Initial plan

* test: add integration tests and fix denyAccessPretty testability

- Replace version_compare(MW_VERSION,...) with method_exists() in
  ResponseFactory::denyAccessPretty() so both title-setter branches
  (setPageTitle for MW<1.41, setPageTitleMsg for MW>=1.41) are reachable
  in tests without redefining the MW_VERSION constant.

- Add tests/phpunit/integration/CrawlerProtectionIntegrationTest.php
  (MediaWikiIntegrationTestCase) covering:
    * Service-container wiring: both services resolve from the real container
    * Hook registration: MediaWikiPerformAction and SpecialPageBeforeExecute
      are registered
    * Real OutputPage state: denyAccessPretty() sets HTTP 403 on a genuine
      OutputPage (CI matrix runs REL1_39 + REL1_43, covering both
      method_exists() branches)
    * End-to-end behaviour: anonymous users are blocked on protected actions
      and special pages; registered users are not

- Add unit tests for both branches of the new method_exists() logic in
  ResponseFactoryTest: modern path (setPageTitleMsg called, setPageTitle
  never) and legacy path (anonymous stub without setPageTitleMsg triggers
  setPageTitle).

- composer.json: restrict standalone `composer phpunit` to unit/ only so
  integration tests (which need a real MediaWiki install) are not run in
  isolation; register integration test namespace in autoload-dev.

- ci.yml: convert phpunit matrix to explicit include entries and enable
  pcov coverage on the REL1_43/PHP 8.2 job, passing --coverage-text to
  phpunit when coverage != none.

Closes #50

* Operationalize Copilot guidance with repo-specific rules, correct scope, and agent setup workflow (#61)

* Initial plan

* Overhaul Copilot instructions with repo-specific CI architecture and testing guidance

* Clarify trimmed guidance and Copilot setup workflow intent

* Restore comprehensive MediaWiki instruction guidance

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* ci: pin actions to SHAs, extend matrix to MW 1.44/1.45, add Dependabot, enforce minus-x (#59)

* Initial plan

* ci: hygiene improvements - pin actions, extend matrix, add dependabot

* ci: skip minus-x on REL1_39 where MediaWiki does not ship it

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Bump actions/checkout from 4.1.7 to 7.0.1 (#65)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.7 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](actions/checkout@v4.1.7...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* test: make integration tests version-agnostic for Title/FauxRequest/User

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>

* Add CrawlerProtectionShouldDeny hook for bespoke access policy (#62)

* Initial plan

* Add CrawlerProtectionShouldDeny hook

* Merge candidate/1.7.0 and fix unit tests to not construct HookContainer

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* tests: assert the hook still runs for allowlisted IPs

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Mark denial responses as noindex,nofollow (#63)

* Initial plan

* Send X-Robots-Tag on denials and robot policy on pretty denial page

* Use WebResponse::header() for X-Robots-Tag on the pretty denial path

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Scope X-Robots-Tag to the pretty denial path only

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Resolve merge conflicts with candidate/1.7.0 and bump to 1.7.1

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Downgrade version from 1.7.1 to 1.7.0

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jeffrey Wang <jeffw16@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use canonical request IP for API/REST allowlist; run denial tests under MediaWiki

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Add opt-in X-Forwarded-For allowlist matching for wikis behind a reverse proxy

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Add missing CrawlerProtectionTrustXForwardedFor key to scalar-config unit tests

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Address cicalese review: denial headers, API 403, hook entry point, docs and tests (#66)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address cicalese follow-up: signature test names, real ApiMain coverage, REST path docs

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Make ApiMain integration assertions version-agnostic

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

* Soften ApiMain integration test status assertion comments

Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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.

3 participants