Skip to content

fix: point all three scan paths at events that actually exist - #1

Merged
przemekperon merged 8 commits into
mainfrom
fix/audit-critical
Aug 23, 2026
Merged

fix: point all three scan paths at events that actually exist#1
przemekperon merged 8 commits into
mainfrom
fix/audit-critical

Conversation

@przemekperon

Copy link
Copy Markdown
Contributor

Problem

The extension was functionally dead: none of the three scan paths ever ran, and the ACP module crashed on open.

  • Registrations hooked core.user_add_modify_data, which has no error variable — data.php:44 array_intersect_key silently dropped the injected key
  • Posts read $post_data['message'], which never exists in posting.php; post_text is unset at :645 and only restored at :1807, i.e. after the event
  • Private messages hooked core.ucp_pm_compose_modify_parsed_text, an event that does not exist in phpBB at all
  • ACPfunctions_module.php:600 does new $class_name($this) with no container, while the constructor required 7 arguments → ArgumentCountError
  • Packagingcomposer install hard-failed on a repositories: path entry pointing at ../spamtroll-php-sdk, and vendor/ was gitignored, so the ZIP shipped without the SDK

Changes

All three paths now hook events that exist and expose error, verified against phpBB 3.3.15:

Event Source Rejection
core.ucp_register_data_after ucp_register.php:338 if (!count($error))
core.posting_modify_submission_errors posting.php:1428 if (!count($error) && $submit)
core.ucp_pm_compose_modify_parse_before ucp_pm_compose.php:860 yes

Post content is read from request->variable('message') rather than the stripped post_data, and PM content from the parser with a request fallback.

Also: ACP dependencies resolved from the container; cleanup_logs registered in services.yml so retention is no longer fiction; the audit logger no longer truncates mid-UTF-8 (substr → byte-safe), which used to trigger trigger_error(E_USER_ERROR) on utf8mb4 strict and kill the request — a fail-closed path a Polish post hits easily; repositories: path dropped in favour of Packagist.

Verification

this branch:                          OK (43 tests, 92 assertions)
old listener/logger/ACP restored:     4 errors + 19 failures
composer validate --strict:           ./composer.json is valid   ← QA gate was blocked here

The event stub in the test bootstrap now mirrors phpBB's real array_intersect_key filtering; previously it masked the registration bug.

Found by the audit in spamtroll/docs/audit/plugins/PHPBB.md (K1–K5, W1, W4, W6, W7).

🤖 Generated with Claude Code

przemekperon and others added 8 commits August 23, 2026 19:47
K5: composer.json declared a `path` repository pointing at a sibling
checkout of the PHP SDK (`../spamtroll-php-sdk`, pinned to 0.9.2). On any
machine without that sibling directory `composer install` aborts hard
("The `url` supplied for the path repository does not exist"), so neither
the documented install steps nor CI could ever fetch dependencies. The SDK
is published on Packagist, so the block is simply removed and the
constraint raised to `^0.9.3`.

W6: `composer validate --strict` failed on the `version` field, which is
the first step of the QA workflow — PHPStan and PHPUnit were reported as
`skipped` on every run and had never executed. Field dropped.

Also:
- `require.php` raised to `>=8.2` and the CI matrix moved to 8.2/8.3/8.4
  to match the project baseline.
- `composer.lock` is now committed so release artefacts are reproducible.
- PHPUnit bumped to ^10.5 (9.6 is EOL and unusable on PHP 8.4);
  phpunit.xml.dist migrated to the 10.5 schema (`cacheDirectory`,
  `<source>` instead of `<coverage><include>`).
- New `release.yml` workflow builds `spamtroll_phpbb_<version>.zip`
  containing `ext/spamtroll/phpbb/` **with** a production `vendor/` tree,
  which is what the phpBB Extension Database expects; the job asserts
  `vendor/autoload.php` and the SDK are present before zipping.
- README install steps rewritten accordingly (the old
  `composer create-project` recipe could not have worked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of the three scanners were reachable in production.

K1 — registration. `core.user_add_modify_data` fires inside user_add()
(includes/functions_user.php:290) with vars user_row, cp_data, sql_ary,
notifications_data. There is no `error`, and phpBB discards any key a
listener adds: dispatcher::trigger_event() returns
get_data_filtered(array_keys($data)) (phpbb/event/dispatcher.php:47),
i.e. array_intersect_key (phpbb/event/data.php:44). The registration was
also already past validation at that point. Moved to
`core.ucp_register_data_after` (includes/ucp/ucp_register.php:329-338,
vars submit/data/cp_data/error), reading $data['username'] and
$data['email']; a non-empty $error aborts the form at :339.

K2 — posts. $post_data['message'] never exists: posting.php:642-645 binds
the text into the parser and unsets the key, and only restores it at
:1807, after the event at :1428. `message_parser` is not in the event's
variable list either. We now read the same request field phpBB reads at
posting.php:941 — $request->variable('message', '', true).

K3 — private messages. `core.ucp_pm_compose_modify_parsed_text` does not
exist anywhere in phpBB 3.3.x, so check_pm() was never invoked; the old
handler also wrote into $message_parser->warn_msg, which
ucp_pm_compose.php:876-888 consumes *before* either parse_* event.
Moved to `core.ucp_pm_compose_modify_parse_before`
(includes/ucp/ucp_pm_compose.php:845-868). The *before* variant, not
*after*, because parse_message::parse() replaces
$message_parser->message with the s9e TextFormatter XML representation
(includes/message_parser.php:1251) — unusable as scanner input. Both
variants expose `error`, and ucp_pm_compose.php:947
(`if (!count($error) && $submit)`) is what stops the PM.

Also:
- errors are appended via offsetSet on the pre-existing `error` key, so
  they survive get_data_filtered();
- content is html_entity_decode()d, because phpBB's request layer
  htmlspecialchars()es every string variable
  (phpbb/request/type_cast_helper.php:46);
- handlers bail out unless `submit` is truthy. posting.php:937 and the
  PM equivalent run the whole block on preview and refresh too, where
  raising a blocking error is wrong and a scan would needlessly consume
  the daily quota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…structor

K4: opening ACP → Spamtroll Settings threw
`ArgumentCountError: Too few arguments to function
spamtroll\phpbb\acp\main_module::__construct(), 1 passed, 7 expected`.

phpBB does not build ACP modules through the DI container.
includes/functions_module.php:598-600 does

    $class_name = $this->p_name;
    $this->module = new $class_name($this);

and the file has no reference to $phpbb_container at all, so the
`spamtroll.phpbb.acp.module` service definition was never read by anyone
— dead code, now removed from services.yml.

The constructor is now optional-arg only (it receives the module manager
and ignores it) and main() pulls $config, $language, $request, $template,
$user and $phpbb_container from the global scope, taking the two
extension services out of the container. That is the same pattern phpBB's
own bundled modules use — see includes/acp/acp_extensions.php:41-55.
Those globals are registered by register_compatibility_globals()
(includes/compatibility_globals.php:40,60).

set_dependencies() is kept as a seam so the module stays unit-testable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
W4: cron/task/cleanup_logs.php existed but had no entry in
config/services.yml and no `{ name: cron.task }` tag, so the class was
never instantiated. Consequences: the ACP "Log retention (days)" setting
did nothing, phpbb_spamtroll_log grew unbounded, and personal data (IP,
username, a slice of the content) was retained indefinitely.

Registered following the core pattern
(config/default/container/services_cron.yml), including the
`set_name` call the cron manager needs to address the task by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
W1 — the one real fail-open hole in the extension.

scanner::safe_log() wraps the write in `catch (\Throwable)`, but phpBB
does not throw on SQL errors: \phpbb\db\driver\driver::sql_error() ends in
trigger_error($message, E_USER_ERROR) (driver.php:1028-1031), which runs
msg_handler() → exit_handler(). No catch block intercepts that; the
request dies half way through submitting a post — fail-closed.

Two realistic triggers:

1. Missing phpbb_spamtroll_log (extension copied without running the
   migrations, or a migration that stopped half way).
2. substr($preview, 0, 500) cut by *bytes*. A multibyte character
   straddling byte 500 leaves a broken sequence, which MySQL in strict
   mode rejects on a utf8mb4 column with "Incorrect string value" — the
   same fatal. Any Polish or German post can hit this.

Fixes:
- INSERT now runs between sql_return_on_error(true)/(false), so
  sql_error() returns the error array instead of triggering the fatal
  (the `if (!$this->return_on_error)` guard at driver.php:989) and the
  existing catch in the scanner sees an ordinary failure.
- All string columns are cut with utf8_substr() (phpBB always loads
  includes/utf/utf_tools.php — common.php:87), falling back to mb_substr()
  and finally to a byte cut that strips a trailing partial sequence.
  Character-based truncation is also what the VCHAR columns want.
- `symbols` is built by dropping whole entries until the encoded JSON
  fits, instead of truncating the JSON text into an unparseable document
  (audit N1).

Also W4/second half: cleanup() deletes in batches of 500 via a
SELECT log_id → DELETE ... IN() round trip instead of one unbounded
DELETE. The first run against a board that has been logging without
retention would otherwise hold a very long lock. The round trip keeps it
portable; PostgreSQL has no `DELETE ... LIMIT`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
W7: tests/bootstrap.php stubbed \phpbb\event\data with a public $data
array and a set_data() that swapped the whole thing, so adding a brand
new key "worked" and registration_test passed against code that was a
no-op in production.

The stub is now a port of the real class, including get_data_filtered()
— array_intersect_key($this->data, array_flip($keys)),
phpbb/event/data.php:44 — and $data is private, as it is upstream.
tests/support/event_harness.php reproduces
\phpbb\event\dispatcher::trigger_event() end to end
(phpbb/event/dispatcher.php:43-48): build the event from the documented
variable list, run the handler, hand back only get_data_filtered(). Tests
assert on that return value, which is what phpBB actually extract()s.

The harness also carries the three real variable lists, so the fixtures
double as executable documentation — notably that post_data has neither
`message` nor `post_text` at core.posting_modify_submission_errors.

Supporting doubles: fake_request (applies the same htmlspecialchars the
request layer does, phpbb/request/type_cast_helper.php:46),
fake_message_parser, fake_db (records the row, the return_on_error flips,
and throws where phpBB would trigger_error(E_USER_ERROR)), recording_http
(records call count and request bodies).

New/rewritten suites: tests/listener/{registration,post,pm}_test.php,
tests/logger/logger_test.php, tests/acp/main_module_test.php.

Verified as regression tests. Against the pre-fix
event/main_listener.php, service/logger.php and acp/main_module.php the
suite reports 19 failures and 4 errors out of 43; against the fixed tree
it is 43/43 green. Every "was not scanned" test asserts on the recorded
HTTP call count rather than on an exception, so it cannot be satisfied by
the scanner's fail-open catch swallowing a guard.

Also fixed a dead PHPStan ignore: '#given\\.$#i' — NEON single quotes are
literal, so that pattern looked for a backslash and never matched
anything. Replaced with a targeted rule for test doubles standing in for
un-autoloadable phpBB types, which let tests/ come back into the analysis
set instead of being excluded file by file.

PHPStan level 5 reports no errors; `composer validate --strict` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also annotates the three scan-path bullets and the cron bullet in the
0.1.0 entry as never having been functional, rather than rewriting a
released section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing calls it; main() resolves everything from the global scope the
way phpBB's bundled modules do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@przemekperon
przemekperon merged commit 2af60ad into main Aug 23, 2026
3 checks passed
@przemekperon
przemekperon deleted the fix/audit-critical branch August 23, 2026 22:30
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.

1 participant