Skip to content

fix multiple issues found during pre-release review#154

Merged
mambax7 merged 5 commits intoXOOPS:masterfrom
mambax7:pre-release-review
Apr 7, 2026
Merged

fix multiple issues found during pre-release review#154
mambax7 merged 5 commits intoXOOPS:masterfrom
mambax7:pre-release-review

Conversation

@mambax7
Copy link
Copy Markdown
Contributor

@mambax7 mambax7 commented Apr 7, 2026

  • Yaml dumpWrapped: use exit guard instead of PHP comment to prevent code execution when content contains closing comment sequence
  • JWT JsonWebToken: use strict comparison for claim validation
  • JWT TokenReader: validate Bearer scheme on Authorization header only; custom headers still accept bare tokens for backward compatibility
  • Metagen purifyText: add explicit encoding, fix newline replacement
  • SendmailRunner: close stdin immediately on empty payload to prevent hang
  • TableLoad: escape backticks in column names
  • FilterInput WEBURL: reject protocol-relative URLs
  • Tables quoteDefaultClause: escape single quotes in DDL default values
  • Replace header('HTTP/1.0 404 Not Found') with http_response_code(404) in all directory index guards
  • Update CHANGELOG for 1.3.0-RC1
  • Update phpstan-baseline.neon for changed error signature

Summary by CodeRabbit

  • Security

    • Stricter JWT claim checks and Authorization header enforcement (Bearer only for Authorization; bare tokens allowed on custom headers)
    • Safer gzip decompression with streaming size checks and clearer decompression errors
    • Reject protocol‑relative URLs in web URL filtering
    • Improved escaping for SQL identifiers and DEFAULT values
  • Bug Fixes

    • Prevent mail runner from hanging on empty messages
    • Fix text purification for proper entity decoding and newline removal
    • Unified 404 response handling across entry points
  • Changed

    • Wrapped YAML format switched to a safer PHP exit guard while preserving legacy support
  • Chores

    • Removed duplicate review tool configuration from CI config
  • Tests

    • Added unit tests covering JWT, URL filtering, YAML wrappers, DB defaults, mail runner, decompression, and text purification

  - Yaml dumpWrapped: use exit guard instead of PHP comment to prevent
    code execution when content contains closing comment sequence
  - JWT JsonWebToken: use strict comparison for claim validation
  - JWT TokenReader: validate Bearer scheme on Authorization header only;
    custom headers still accept bare tokens for backward compatibility
  - Metagen purifyText: add explicit encoding, fix newline replacement
  - SendmailRunner: close stdin immediately on empty payload to prevent hang
  - TableLoad: escape backticks in column names
  - FilterInput WEBURL: reject protocol-relative URLs
  - Tables quoteDefaultClause: escape single quotes in DDL default values
  - Replace header('HTTP/1.0 404 Not Found') with http_response_code(404)
    in all directory index guards
  - Update CHANGELOG for 1.3.0-RC1
  - Update phpstan-baseline.neon for changed error signature
Copilot AI review requested due to automatic review settings April 7, 2026 03:34
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
duplicated mapping key in ".coderabbit.yaml" (46:3)

 43 |         Review test code for prop ...
 44 |         case coverage. Tests must ...
 45 |         supported PHPUnit setup.
 46 |   tools:
--------^
 47 |     phpcs:
 48 |       enabled: true
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f58bcdda-21f9-45f5-8a92-1d778a5725a2

📥 Commits

Reviewing files that changed from the base of the PR and between f28b50e and 09643b3.

📒 Files selected for processing (4)
  • phpstan-baseline.neon
  • src/Database/TableLoad.php
  • src/FilterInput.php
  • src/Security/DecompressionException.php
✅ Files skipped from review due to trivial changes (1)
  • src/Security/DecompressionException.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Database/TableLoad.php
  • phpstan-baseline.neon

📝 Walkthrough

Walkthrough

Security and robustness fixes for 1.3.0-RC1: stricter JWT claim/header parsing, safer wrapped YAML format with dual-format loading, SQL identifier/DDL escaping, protocol-relative URL rejection, streaming-safe gzip decompression with a new exception, Sendmail stdin handling for empty payloads, standardized 404 handling, corrected entity/newline decoding, and added tests.

Changes

Cohort / File(s) Summary
404 Response Standardization
index.php, src/index.php, src/Database/index.php, src/Jwt/index.php, src/Key/index.php, src/Module/Helper/index.php, src/Module/index.php
Replace raw header('HTTP/1.0 404 Not Found') with http_response_code(404) and add exit; to immediately terminate execution.
JWT Security & Header Parsing
src/Jwt/JsonWebToken.php, src/Jwt/TokenReader.php, tests/unit/Jwt/JsonWebTokenTest.php, tests/unit/Jwt/TokenReaderTest.php
Use strict claim comparison (!==) in JWT decode; require Authorization: Bearer <token> for the Authorization header while allowing bare tokens on custom headers; add/adjust unit tests for these behaviors.
YAML Wrapper Format & Tests
src/Yaml.php, tests/unit/YamlTest.php
Change dumpWrapped() to emit <?php __halt_compiler(); ?> guard; loader now supports both __halt_compiler() and legacy <?php /* ... */ ?> wrappers and extracts YAML between ---/...; add tests for guards, edge cases, and round trips.
SQL Identifier / DDL Escaping & Tests
src/Database/TableLoad.php, src/Database/Tables.php, tests/unit/Database/TablesTest.php
Escape embedded backticks in column identifiers (\`` → ````) and escape single quotes in DDL DEFAULT values ('''`); add tests for DEFAULT quoting and CURRENT_TIMESTAMP handling.
Input Validation & Tests
src/FilterInput.php, tests/unit/FilterInputTest.php
Trim WEBURL input, explicitly reject protocol-relative URLs (leading //), guard scheme checks by verifying parse_url() returns an array, and add tests for protocol-relative, http, relative, whitespace-prefixed, and javascript: inputs.
Sendmail I/O Robustness & Tests
src/Mail/SendmailRunner.php, tests/unit/Mail/SendmailRunnerTest.php
When RFC‑822 payload is empty, close stdin early (fclose) and mark stdin closed to avoid write/hang loops; add POSIX-only test ensuring empty payload does not hang.
Safe Gzip Decompression & Exception
src/Security/Serializer.php, src/Security/DecompressionException.php
Replace single-call gzdecode() with streaming safeGzipDecode() using inflate_init/inflate_add, chunked decompression with size enforcement during inflate, finalization checks, and throw new DecompressionException on failure or oversized output.
Text Purification Fixes & Tests
src/Metagen.php, tests/unit/MetagenTest.php
Use html_entity_decode($text, ENT_QUOTES, self::ENCODING) and replace actual \r and \n characters (["\r","\n"]) when normalizing newlines; add tests for newline removal and encoding-aware entity decoding.
Static Analysis / Config & Changelog
phpstan-baseline.neon, .coderabbit.yaml, CHANGELOG.md
Adjust phpstan baseline ignore entries (refine/remove some ignores and change types/counts), remove reviews.tools block from .coderabbit.yaml, and add 1.3.0-RC1 changelog entry documenting changes.
Tests: General Additions
tests/unit/... (multiple files)
Add/extend unit tests across JWT, TokenReader, YAML, Database/Tables, FilterInput, SendmailRunner, Metagen to cover new behaviors and edge cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Feature/update copyright #144 — Overlapping changes touching SendmailRunner, Database/Tables, and YAML helpers; likely related at code-level for I/O and serialization adjustments.

Poem

🐰 I nibbled bugs and stitched each stitch,

Tokens tightened, YAML found a switch.
Quotes escaped, streams now mind their size,
404s exit clean — no surprise.
RC1 hops forth with tests and bright eyes. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the overall intent of the changeset—fixing multiple issues found during pre-release review. The PR includes security hardening (JWT strict comparison, YAML exit guard, URL validation), bug fixes (SendmailRunner hang, HTML entity decoding, newline replacement, SQL escaping), and standardization (404 response handling).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 PHPStan (2.1.46)

PHPStan was skipped because the config uses disallowed bootstrapFiles, bootstrapFile, or includes directives.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 7, 2026

Codecov Report

❌ Patch coverage is 33.84615% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 43.51%. Comparing base (36d9d5b) to head (09643b3).
⚠️ Report is 20 commits behind head on master.

Files with missing lines Patch % Lines
src/Security/Serializer.php 0.00% 30 Missing ⚠️
src/Database/index.php 0.00% 2 Missing ⚠️
src/Jwt/index.php 0.00% 2 Missing ⚠️
src/Key/index.php 0.00% 2 Missing ⚠️
src/Module/Helper/index.php 0.00% 2 Missing ⚠️
src/Module/index.php 0.00% 2 Missing ⚠️
src/index.php 0.00% 2 Missing ⚠️
src/Database/TableLoad.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             master     #154       +/-   ##
=============================================
+ Coverage     33.23%   43.51%   +10.27%     
- Complexity     1058     1178      +120     
=============================================
  Files            45       47        +2     
  Lines          2750     2999      +249     
=============================================
+ Hits            914     1305      +391     
+ Misses         1836     1694      -142     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mambax7 mambax7 requested review from Copilot and removed request for Copilot April 7, 2026 03:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 applies a set of pre-release hardening fixes across XMF, primarily targeting security edge-cases (YAML wrapping, JWT validation, URL filtering, SQL identifier/value escaping) plus a few operational and consistency improvements (sendmail delivery hang, 404 index guards, baseline/changelog updates).

Changes:

  • Harden wrapped YAML output (dumpWrapped) by switching to a PHP exit guard, and tighten JWT handling (strict claim comparison + Bearer parsing for Authorization).
  • Fix/strengthen sanitization and SQL construction (WEBURL protocol-relative rejection, backtick escaping for column names, escaping quotes in DDL default values).
  • Operational/maintenance updates: prevent sendmail hangs on empty payload, standardize directory index guards to http_response_code(404); exit;, and update changelog + PHPStan baseline.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/Yaml.php Changes wrapped YAML format to use <?php exit; ?> guard.
src/Jwt/JsonWebToken.php Uses strict comparison for asserted claim validation.
src/Jwt/TokenReader.php Requires Bearer scheme for Authorization header parsing.
src/Metagen.php Fixes encoding passed to html_entity_decode() and corrects newline replacement.
src/Mail/SendmailRunner.php Closes stdin immediately for empty payloads to prevent process hangs.
src/FilterInput.php Rejects protocol-relative URLs in WEBURL cleaning.
src/Database/Tables.php Escapes single quotes in DDL default values.
src/Database/TableLoad.php Escapes backticks in column identifiers during inserts.
index.php Standardizes root index guard to http_response_code(404); exit;.
src/index.php Standardizes src index guard to http_response_code(404); exit;.
src/Database/index.php Standardizes directory index guard to http_response_code(404); exit;.
src/Jwt/index.php Standardizes directory index guard to http_response_code(404); exit;.
src/Key/index.php Standardizes directory index guard to http_response_code(404); exit;.
src/Module/index.php Standardizes directory index guard to http_response_code(404); exit;.
src/Module/Helper/index.php Standardizes directory index guard to http_response_code(404); exit;.
phpstan-baseline.neon Updates baseline patterns/counts to match new/static-analysis findings.
CHANGELOG.md Documents 1.3.0-RC1 changes.
Comments suppressed due to low confidence (3)

src/FilterInput.php:281

  • parse_url('') can return false. After setting $result = '' for protocol-relative URLs, $urlparts may be false, and accessing $urlparts['scheme'] will emit warnings. Consider short-circuiting when $result === '' or normalizing with $urlparts = parse_url($result) ?: []; before reading offsets.
                // reject protocol-relative URLs (//evil.example) and non-http(s) schemes
                if (str_starts_with((string) $result, '//')) {
                    $result = '';
                }
                $urlparts = parse_url($result);
                if (
                    !empty($urlparts['scheme'])
                    && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https')
                ) {

src/FilterInput.php:288

  • Add a unit test covering the new WEBURL behavior (reject //evil.example), including the empty-string path, to prevent regressions and ensure no warnings are raised when the URL is invalid/blank.
            case 'WEBURL':
                $result = (string) $this->process($source);
                // reject protocol-relative URLs (//evil.example) and non-http(s) schemes
                if (str_starts_with((string) $result, '//')) {
                    $result = '';
                }
                $urlparts = parse_url($result);
                if (
                    !empty($urlparts['scheme'])
                    && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https')
                ) {
                    $result = '';
                }
                // do not allow quotes, tag brackets or controls
                if (!preg_match('#^[^"<>\x00-\x1F]+$#', $result)) {
                    $result = '';
                }
                break;

src/Yaml.php:162

  • Add/adjust a unit test to cover the security case that motivated this change (YAML content containing the */ sequence). The test should assert that dumpWrapped() output cannot be turned into executable PHP by closing a comment early, and that loadWrapped() still round-trips correctly.
    public static function dumpWrapped($var, $inline = 4, $indent = 4)
    {
        try {
            $yamlString = VendorYaml::dump($var, $inline, $indent);
            $ret = empty($yamlString) ? false : "<?php exit; ?>\n---\n" . $yamlString . "\n...\n";
        } catch (\Throwable $e) {
            static::logError($e);
            $ret = false;
        }
        return $ret;

Comment on lines 140 to 158
@@ -155,7 +154,7 @@ public static function dumpWrapped($var, $inline = 4, $indent = 4)
{
try {
$yamlString = VendorYaml::dump($var, $inline, $indent);
$ret = empty($yamlString) ? false : "<?php\n/*\n---\n" . $yamlString . "\n...\n*/\n";
$ret = empty($yamlString) ? false : "<?php exit; ?>\n---\n" . $yamlString . "\n...\n";
} catch (\Throwable $e) {
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The wrapper format changed to an exit guard, but the loadWrapped()/readWrapped() docblocks below still describe the old /* ... */ comment wrapper. To keep the public API docs consistent, update the surrounding documentation to describe both supported wrappers (old comment wrapper + new exit guard).

Copilot uses AI. Check for mistakes.
Comment on lines 100 to 116
public static function fromHeader($key, $assertClaims = array(), $headerName = 'Authorization')
{
$header = Request::getHeader($headerName, '');
if (empty($header)) {
return false;
}
$header = trim($header);
$space = strpos($header, ' '); // expecting "Bearer base64-token-string"
if (false !== $space) {
$header = substr($header, $space);
if (strcasecmp($headerName, 'Authorization') === 0) {
$parts = explode(' ', $header, 2);
if (count($parts) !== 2 || strcasecmp($parts[0], 'Bearer') !== 0) {
return false;
}
$token = trim($parts[1]);
} else {
$token = $header;
}
$token = trim($header);
return static::fromString($key, $token, $assertClaims);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

TokenReader::fromHeader() behavior changed to require the Bearer scheme for Authorization. There is an existing TokenReaderTest::testFromHeader() placeholder marked incomplete; it should be implemented to cover valid/invalid Authorization headers (missing scheme, wrong scheme, extra whitespace) and to verify custom headers still accept bare tokens for backward compatibility.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 90 to 98
foreach ($assertClaims as $claim => $assert) {
if (!property_exists($values, $claim)) {
return false;
}

if ($values->$claim != $assert) {
if ($values->$claim !== $assert) {
return false;
}
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Add a unit test demonstrating the security fix for strict claim comparison (e.g., asserting 0 should not match '0', false should not match 0, etc.). This will prevent accidental reintroduction of loose comparisons in future refactors.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 404 to 420
@@ -416,7 +416,7 @@ protected static function purifyText($text, $keyword = false)
$text = str_replace('?', ' ', $text);
$text = str_replace('"', ' ', $text);
$text = str_replace('-', ' ', $text);
$text = str_replace('\n', ' ', $text);
$text = str_replace(["\r", "\n"], ' ', $text);
$text = str_replace('&#8213;', ' ', $text);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

There are existing Metagen tests, but this change isn’t directly covered: add a focused unit test (via reflection like other tests) to assert purifyText() replaces real newlines (\r/\n) and that entity decoding uses the expected encoding constant. This ensures the bugfix can’t regress silently.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 217 to +227
try {
$len = strlen($rfc822);
$off = 0;
$stdinOpen = true;
$chunkSize = 8192;

if ($len === 0) {
fclose($stdin);
$stdinOpen = false;
}

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Add a unit test for the empty-payload path to ensure deliver() does not hang when $rfc822 is empty (stdin must be closed promptly). Since this is a regression-prone operational bug, having an automated test here is important.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 892 to 909
protected function quoteDefaultClause($default)
{
// . (($column['COLUMN_DEFAULT'] === null) ? '' : " DEFAULT '" . $column['COLUMN_DEFAULT'] . "' ")
// no default specified
if (null === $default) {
return '';
}

// functions should not be quoted
// this section will need expanded when XOOPS minimum is no longer a mysql 5 version
// Until mysql 8, only allowed function is CURRENT_TIMESTAMP
if ($default === 'CURRENT_TIMESTAMP') {
return ' DEFAULT CURRENT_TIMESTAMP ';
}

// surround default with quotes
return " DEFAULT '{$default}' ";
// surround default with quotes — escape embedded single quotes for valid DDL
return " DEFAULT '" . str_replace("'", "''", $default) . "' ";
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Add a unit test covering default values containing single quotes (e.g. O'Reilly) to verify quoteDefaultClause() produces valid DDL by doubling embedded quotes. tests/unit/Database/TablesTest.php already exists, so this should be straightforward to add.

Copilot generated this review using guidance from repository custom instructions.
message: '#^Cannot cast mixed to string\.$#'
identifier: cast.string
count: 7
count: 8
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The baseline entry for src/FilterInput.php increases the cast.string suppression count (7 → 8), which indicates a new PHPStan issue was introduced and then baselined. Prefer fixing the underlying new warning instead of expanding the baseline so we keep incremental static-analysis improvements.

Suggested change
count: 8
count: 7

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Around line 3-21: The changelog entries currently placed under the release
header "## [1.3.0-RC1] - 2026-04-06" must be moved under the "[Unreleased]"
section per project guidelines; find the block starting with "## [1.3.0-RC1] -
2026-04-06" and relocate its nested "Security", "Bug Fixes", and "Changed"
entries beneath the existing "## [Unreleased]" header (merging with existing
Unreleased content if present) and remove the empty or duplicate RC1 release
header so the PR changes are only documented under [Unreleased] until the
release is cut. Ensure section headings (Security, Bug Fixes, Changed) and
bullet items are preserved exactly.

In `@src/Yaml.php`:
- Around line 143-145: Replace the PHP exit guard in Yaml.php that currently
emits "<?php exit; ?>" with a __halt_compiler() guard so PHP stops parsing the
file entirely (preventing embedded "<?php" in YAML values from being lexed);
locate the code emitting the wrapper (the block that constructs the php guard
around YAML output, e.g., the method that writes the PHP/YAML wrapper) and
change it to output "<?php __halt_compiler(); ?>" instead of "<?php exit; ?>",
and add a regression test that writes/reads a YAML payload containing "<?php"
(or a value like code: '<?php') to ensure parsing no longer fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a5e5037-d612-4d5e-9988-770aa0ab154f

📥 Commits

Reviewing files that changed from the base of the PR and between 04d2c44 and feaadc0.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • index.php
  • phpstan-baseline.neon
  • src/Database/TableLoad.php
  • src/Database/Tables.php
  • src/Database/index.php
  • src/FilterInput.php
  • src/Jwt/JsonWebToken.php
  • src/Jwt/TokenReader.php
  • src/Jwt/index.php
  • src/Key/index.php
  • src/Mail/SendmailRunner.php
  • src/Metagen.php
  • src/Module/Helper/index.php
  • src/Module/index.php
  • src/Yaml.php
  • src/index.php

Comment on lines +3 to +21
## [1.3.0-RC1] - 2026-04-06

### Security
* Fix wrapped YAML (`dumpWrapped`) using `<?php exit; ?>` guard instead of PHP block comment to prevent code execution when YAML content contains `*/`
* Fix JWT claim validation using strict comparison (`!==`) to prevent type-juggling bypass in `JsonWebToken::decode()`
* Fix JWT `TokenReader::fromHeader()` to require `Bearer` scheme for Authorization headers; custom headers still accept bare tokens
* Fix `TableLoad::loadTableFromArray()` to escape backticks in column names, preventing identifier injection
* Fix `FilterInput::WEBURL` to reject protocol-relative URLs (`//evil.example`) that bypassed scheme validation
* Fix `Tables::quoteDefaultClause()` to escape single quotes in DDL default values, preventing broken migrations

### Bug Fixes
* Fix `Metagen::purifyText()` calling `html_entity_decode()` without explicit encoding (now uses `self::ENCODING`)
* Fix `Metagen::purifyText()` replacing literal `'\n'` instead of actual newline characters
* Fix `SendmailRunner` hanging indefinitely when `$rfc822` is empty by closing stdin immediately

### Changed
* Wrapped YAML files now use `<?php exit; ?>` instead of `<?php /* */ ?>` format; `loadWrapped()` reads both old and new formats transparently
* Replace `header('HTTP/1.0 404 Not Found')` with `http_response_code(404); exit;` in all directory index guards

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move these entries under [Unreleased] instead of creating a release header now.

Line 3 introduces ## [1.3.0-RC1] - 2026-04-06, but this PR’s changes should be tracked under [Unreleased] until the release is cut.

Suggested changelog structure update
-## [1.3.0-RC1] - 2026-04-06
+## [Unreleased]

 ### Security
 * Fix wrapped YAML (`dumpWrapped`) using `<?php exit; ?>` guard instead of PHP block comment to prevent code execution when YAML content contains `*/`
 * Fix JWT claim validation using strict comparison (`!==`) to prevent type-juggling bypass in `JsonWebToken::decode()`
 ...

As per coding guidelines, "Pull request changes must be documented in CHANGELOG.md under [Unreleased]".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## [1.3.0-RC1] - 2026-04-06
### Security
* Fix wrapped YAML (`dumpWrapped`) using `<?php exit; ?>` guard instead of PHP block comment to prevent code execution when YAML content contains `*/`
* Fix JWT claim validation using strict comparison (`!==`) to prevent type-juggling bypass in `JsonWebToken::decode()`
* Fix JWT `TokenReader::fromHeader()` to require `Bearer` scheme for Authorization headers; custom headers still accept bare tokens
* Fix `TableLoad::loadTableFromArray()` to escape backticks in column names, preventing identifier injection
* Fix `FilterInput::WEBURL` to reject protocol-relative URLs (`//evil.example`) that bypassed scheme validation
* Fix `Tables::quoteDefaultClause()` to escape single quotes in DDL default values, preventing broken migrations
### Bug Fixes
* Fix `Metagen::purifyText()` calling `html_entity_decode()` without explicit encoding (now uses `self::ENCODING`)
* Fix `Metagen::purifyText()` replacing literal `'\n'` instead of actual newline characters
* Fix `SendmailRunner` hanging indefinitely when `$rfc822` is empty by closing stdin immediately
### Changed
* Wrapped YAML files now use `<?php exit; ?>` instead of `<?php /* */ ?>` format; `loadWrapped()` reads both old and new formats transparently
* Replace `header('HTTP/1.0 404 Not Found')` with `http_response_code(404); exit;` in all directory index guards
## [Unreleased]
### Security
* Fix wrapped YAML (`dumpWrapped`) using `<?php exit; ?>` guard instead of PHP block comment to prevent code execution when YAML content contains `*/`
* Fix JWT claim validation using strict comparison (`!==`) to prevent type-juggling bypass in `JsonWebToken::decode()`
* Fix JWT `TokenReader::fromHeader()` to require `Bearer` scheme for Authorization headers; custom headers still accept bare tokens
* Fix `TableLoad::loadTableFromArray()` to escape backticks in column names, preventing identifier injection
* Fix `FilterInput::WEBURL` to reject protocol-relative URLs (`//evil.example`) that bypassed scheme validation
* Fix `Tables::quoteDefaultClause()` to escape single quotes in DDL default values, preventing broken migrations
### Bug Fixes
* Fix `Metagen::purifyText()` calling `html_entity_decode()` without explicit encoding (now uses `self::ENCODING`)
* Fix `Metagen::purifyText()` replacing literal `'\n'` instead of actual newline characters
* Fix `SendmailRunner` hanging indefinitely when `$rfc822` is empty by closing stdin immediately
### Changed
* Wrapped YAML files now use `<?php exit; ?>` instead of `<?php /* */ ?>` format; `loadWrapped()` reads both old and new formats transparently
* Replace `header('HTTP/1.0 404 Not Found')` with `http_response_code(404); exit;` in all directory index guards
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 3 - 21, The changelog entries currently placed
under the release header "## [1.3.0-RC1] - 2026-04-06" must be moved under the
"[Unreleased]" section per project guidelines; find the block starting with "##
[1.3.0-RC1] - 2026-04-06" and relocate its nested "Security", "Bug Fixes", and
"Changed" entries beneath the existing "## [Unreleased]" header (merging with
existing Unreleased content if present) and remove the empty or duplicate RC1
release header so the PR changes are only documented under [Unreleased] until
the release is cut. Ensure section headings (Security, Bug Fixes, Changed) and
bullet items are preserved exactly.

src/Yaml.php Outdated
Comment on lines +143 to +145
* The wrap is a php exit guard followed by YAML section markers '---' and
* '...'. The php wrapper prevents the file contents from being revealed
* by serving the file directly from a poorly configured server.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In PHP 8.2-8.5, does __halt_compiler();prevent the remainder of a file from being parsed, and is it safer than for embedding arbitrary data after a PHP stub?

💡 Result:

Yes. In PHP 8.2–8.5, __halt_compiler(); stops compilation/parsing at that point, so the remainder of the file is not parsed as PHP and can safely contain arbitrary bytes; PHP also defines __COMPILER_HALT_OFFSET__ so you can locate/read the embedded data. [1] The construct is explicitly described as one that “stops parsing.” [2]

It is generally safer than putting data after a stub like <?php exit; ?> because exit only affects runtime execution—PHP still needs to lex/parse the entire file up front, and any later <?php ... ?> sequences (or other tag-like bytes, depending on configuration) inside your embedded data can cause parsing to resume and potentially trigger parse errors (or, if reached in some other execution path, execution). Parsing and execution are separate phases in PHP. [3]

For this reason, PHAR stubs are required to end with __HALT_COMPILER();, specifically to prevent the archive payload from being parsed as PHP and to mark the payload offset. [4]

Notes/constraints: __halt_compiler() can only be used in the outermost scope (not inside a function/class). [1]

Sources

  1. PHP manual: __halt_compiler() [1]
  2. PHP RFC (language constructs syntax changes): “stops parsing” [2]
  3. PHP manual (parser behavior): parsing happens before execution [3]
  4. PHP manual: Phar stub must conclude with __HALT_COMPILER(); [4]

🏁 Script executed:

cat -n src/Yaml.php | sed -n '140,160p'

Repository: XOOPS/xmf

Length of output: 1144


🏁 Script executed:

# Check test coverage for loadWrapped and dumpWrapped
rg -A 20 -B 5 'dumpWrapped|loadWrapped' tests/unit/YamlTest.php | head -100

Repository: XOOPS/xmf

Length of output: 1940


🏁 Script executed:

# Search for any payloads with <?php in tests
rg '<?php' tests/ --type php -C 3

Repository: XOOPS/xmf

Length of output: 7929


Replace exit with __halt_compiler() to prevent arbitrary YAML payloads from triggering PHP parse errors.

Line 157 uses <?php exit; ?> to guard YAML content, but this only prevents runtime execution—PHP still parses the entire file before execution. A YAML value like code: '<?php' will trigger the parser to interpret the embedded <?php sequence as a PHP tag during the lexing phase, causing parse errors before exit runs. Use __halt_compiler() instead, which stops parsing entirely and is the same guard PHAR stubs use to safely embed arbitrary binary/text data.

🔒 Proposed fix
-            $ret = empty($yamlString) ? false : "<?php exit; ?>\n---\n" . $yamlString . "\n...\n";
+            $ret = empty($yamlString) ? false : "<?php __halt_compiler(); ?>\n---\n" . $yamlString . "\n...\n";

Also add a regression test with a payload containing <?php to catch this failure mode; current tests cover '---' and '...' markers but not PHP tag injection.

Also applies to: 157-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Yaml.php` around lines 143 - 145, Replace the PHP exit guard in Yaml.php
that currently emits "<?php exit; ?>" with a __halt_compiler() guard so PHP
stops parsing the file entirely (preventing embedded "<?php" in YAML values from
being lexed); locate the code emitting the wrapper (the block that constructs
the php guard around YAML output, e.g., the method that writes the PHP/YAML
wrapper) and change it to output "<?php __halt_compiler(); ?>" instead of "<?php
exit; ?>", and add a regression test that writes/reads a YAML payload containing
"<?php" (or a value like code: '<?php') to ensure parsing no longer fails.

  - Use __halt_compiler() instead of exit in YAML wrapper to prevent
    PHP lexer from parsing embedded PHP tags in YAML content
  - Update Yaml docblocks for dumpWrapped, loadWrapped, readWrapped
  - Fix PHPStan baseline: add @var annotation in FilterInput instead
    of expanding cast.string count; remove 2 resolved baseline entries
  - Add Tables quoteDefaultClause tests for embedded quotes
  - Add Yaml regression test for content containing <?php tags
  - Add FilterInput WEBURL tests for protocol-relative and scheme checks
  - Add JWT strict claim comparison and TokenReader fromHeader tests
  - Add Metagen purifyText and SendmailRunner empty payload tests
  - Fix duplicate tools key in .coderabbit.yaml
  - Serializer fromLegacy: use streaming gzip decompression with per-chunk
    size enforcement to prevent zip-bomb memory exhaustion
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 26 out of 26 changed files in this pull request and generated 3 comments.

Comment on lines +231 to +247
public function testPurifyTextReplacesNewlines()
{
$method = new \ReflectionMethod('Xmf\Metagen', 'purifyText');
$actual = $method->invokeArgs(null, array("line one\nline two\rline three"));
$this->assertStringNotContainsString("\n", $actual);
$this->assertStringNotContainsString("\r", $actual);
$this->assertStringContainsString('line one', $actual);
$this->assertStringContainsString('line two', $actual);
$this->assertStringContainsString('line three', $actual);
}

public function testPurifyTextDecodesEntitiesWithEncoding()
{
$method = new \ReflectionMethod('Xmf\Metagen', 'purifyText');
$input = 'caf&eacute; cr&egrave;me';
$actual = $method->invokeArgs(null, array($input));
$this->assertStringContainsString('café', $actual);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

purifyText() is a protected method, but these new tests invoke it via ReflectionMethod without calling setAccessible(true). This will throw when running the tests (cannot invoke non-public method). Set the reflection method accessible before invokeArgs() (as done in other tests in this file, e.g. testAsPlainText() / testHtml2textNumericEntities()).

Copilot uses AI. Check for mistakes.
Comment on lines 271 to 279
case 'WEBURL':
/** @var string $result */
$result = (string) $this->process($source);
// allow only relative, http or https
// reject protocol-relative URLs (//evil.example) and non-http(s) schemes
if (str_starts_with($result, '//')) {
$result = '';
}
$urlparts = parse_url($result);
if (
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The new protocol-relative URL rejection only checks str_starts_with($result, '//') without trimming. Inputs like ' //evil.example/path' will bypass this check (and still pass the later regex), defeating the intent of rejecting protocol-relative URLs. Consider trimming/left-trimming $result before the // check (and before parse_url()).

Copilot uses AI. Check for mistakes.
Comment on lines 327 to 333
// Validate size after decoding
self::validateSize($decoded);

// Check for optional gzip compression
// Check for optional gzip compression — stream-decompress to enforce size cap
if (self::isGzip($decoded)) {
$unzipped = gzdecode($decoded);
if ($unzipped === false) {
throw new RuntimeException('Gzip decompression failed');
}
$decoded = $unzipped;
self::validateSize($decoded);
$decoded = self::safeGzipDecode($decoded);
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

This PR introduces a notable behavioral/security change here (streaming gzip decompression via safeGzipDecode()), but it isn't mentioned in the PR description/release notes list. Consider adding a bullet to the PR description/CHANGELOG entry so reviewers and release consumers don’t miss this change.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unit/FilterInputTest.php (1)

113-117: Add an https allow-case to complete scheme coverage.

WEBURL currently allows both http and https; this method validates only one allowed branch.

Suggested addition
+    public function testWeburlAllowsHttpsUrl()
+    {
+        $result = FilterInput::clean('https://example.com/page', 'WEBURL');
+        $this->assertSame('https://example.com/page', $result);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/FilterInputTest.php` around lines 113 - 117, Add a complementary
test case to cover HTTPS by either renaming or adding a new test (e.g.,
testWeburlAllowsHttpsUrl) that calls
FilterInput::clean('https://example.com/page', 'WEBURL') and asserts the
returned value is exactly 'https://example.com/page'; this ensures the WEBURL
branch that allows https is exercised alongside the existing
testWeburlAllowsHttpUrl.
tests/unit/YamlTest.php (1)

145-145: Use the exact legacy wrapper shape in this compatibility fixture.

Current fixture is close, but using the historical <?php /* ... */ ?> form (including closing ?>) would better lock backward-compat behavior.

Proposed test-fixture tweak
-        $oldFormat = "<?php\n/*\n---\none: 1\ntwo: hello\n...\n*/\n";
+        $oldFormat = "<?php /*\n---\none: 1\ntwo: hello\n...\n*/ ?>";

Based on learnings: PHP-wrapped YAML via Yaml::dumpWrapped() / readWrapped() must embed YAML inside <?php /* --- ... */ ?> to prevent direct serving of config files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/YamlTest.php` at line 145, The fixture in tests/unit/YamlTest.php
uses $oldFormat that omits the historical PHP closing tag; update the fixture to
use the exact legacy wrapper shape "<?php /* ... */ ?>" (i.e., include the
closing "?>") so compatibility tests match the real legacy output consumed by
Yaml::dumpWrapped() and Yaml::readWrapped(); locate the $oldFormat definition
and replace its current string with the full "<?php /* --- YAML ... */ ?>"
wrapper including the trailing ?>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Security/Serializer.php`:
- Around line 695-703: The final inflate_add($context, '', ZLIB_FINISH) call
must explicitly handle a false return and verify the stream finished; if
$inflated === false throw a RuntimeException indicating decompression failure,
otherwise append to $output and check strlen($output) > $maxSize as before;
after the flush call also call inflate_get_status($context) and if it !==
ZLIB_STREAM_END throw a RuntimeException about an incomplete/truncated gzip
stream to ensure corrupt or truncated input is rejected.

---

Nitpick comments:
In `@tests/unit/FilterInputTest.php`:
- Around line 113-117: Add a complementary test case to cover HTTPS by either
renaming or adding a new test (e.g., testWeburlAllowsHttpsUrl) that calls
FilterInput::clean('https://example.com/page', 'WEBURL') and asserts the
returned value is exactly 'https://example.com/page'; this ensures the WEBURL
branch that allows https is exercised alongside the existing
testWeburlAllowsHttpUrl.

In `@tests/unit/YamlTest.php`:
- Line 145: The fixture in tests/unit/YamlTest.php uses $oldFormat that omits
the historical PHP closing tag; update the fixture to use the exact legacy
wrapper shape "<?php /* ... */ ?>" (i.e., include the closing "?>") so
compatibility tests match the real legacy output consumed by Yaml::dumpWrapped()
and Yaml::readWrapped(); locate the $oldFormat definition and replace its
current string with the full "<?php /* --- YAML ... */ ?>" wrapper including the
trailing ?>.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5b2a57f-a20f-4c68-a137-1837321861e0

📥 Commits

Reviewing files that changed from the base of the PR and between feaadc0 and c596a80.

📒 Files selected for processing (13)
  • .coderabbit.yaml
  • CHANGELOG.md
  • phpstan-baseline.neon
  • src/FilterInput.php
  • src/Security/Serializer.php
  • src/Yaml.php
  • tests/unit/Database/TablesTest.php
  • tests/unit/FilterInputTest.php
  • tests/unit/Jwt/JsonWebTokenTest.php
  • tests/unit/Jwt/TokenReaderTest.php
  • tests/unit/Mail/SendmailRunnerTest.php
  • tests/unit/MetagenTest.php
  • tests/unit/YamlTest.php
💤 Files with no reviewable changes (1)
  • .coderabbit.yaml
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/FilterInput.php
  • src/Yaml.php
  • phpstan-baseline.neon

  - FilterInput WEBURL: trim input before protocol-relative check to
    prevent whitespace bypass; add regression test
  - Serializer safeGzipDecode: validate final flush result and verify
    stream completion via inflate_get_status to reject truncated input
  - MetagenTest: add setAccessible(true) for consistency with existing tests
  - CHANGELOG: document streaming gzip decompression change
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 27 out of 27 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/FilterInput.php:281

  • parse_url('') returns false, so when $result is emptied (e.g., protocol-relative URL is rejected) the subsequent !empty($urlparts['scheme']) will trigger a warning by indexing into a boolean. Consider short-circuiting when $result === '' or normalizing $urlparts to an array (e.g., parse_url($result) ?: []) before accessing offsets.
                $result = trim((string) $this->process($source));
                // reject protocol-relative URLs (//evil.example) and non-http(s) schemes
                if (str_starts_with($result, '//')) {
                    $result = '';
                }
                $urlparts = parse_url($result);
                if (
                    !empty($urlparts['scheme'])
                    && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https')

Comment on lines 60 to 62

$insertInto .= '`' . $column . '`';
$insertInto .= '`' . str_replace('`', '``', (string) $column) . '`';
$valueClause .= $db->quote($value);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Casting $column to string here is a symptom of missing type information: PHPStan now flags this as “Cannot cast mixed to string” (and it’s added to the baseline). It would be better to tighten the $data/$row phpdoc (e.g., keys are string) so $column is inferred as string and the cast/baseline entry can be avoided.

Copilot uses AI. Check for mistakes.
/**
* Thrown when gzip decompression fails or produces unsafe output.
*
* @category Xmf\Security\DecompressionException
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The @category tag value (Xmf\\Security\\DecompressionException) is inconsistent with other Xmf\\Security classes in this repo (they use the namespace, not the FQCN). Consider updating it to Xmf\\Security for consistency.

Suggested change
* @category Xmf\Security\DecompressionException
* @category Xmf\Security

Copilot uses AI. Check for mistakes.
… tag

  - TableLoad: type $data as array<int, array<string, mixed>> so PHPStan
    infers $column as string; remove cast and 2 baseline entries
  - DecompressionException: fix @category to Xmf\Security for consistency
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 27 out of 27 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/FilterInput.php:281

  • parse_url($result) can return false (e.g., when $result is set to an empty string after rejecting a protocol-relative URL). The subsequent !empty($urlparts['scheme']) then triggers a runtime warning for array access on bool. Consider short-circuiting when $result === '', or validating $urlparts !== false before reading scheme.
            case 'WEBURL':
                /** @var string $result */
                $result = trim((string) $this->process($source));
                // reject protocol-relative URLs (//evil.example) and non-http(s) schemes
                if (str_starts_with($result, '//')) {
                    $result = '';
                }
                $urlparts = parse_url($result);
                if (
                    !empty($urlparts['scheme'])
                    && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https')

  Short-circuit when $result is empty after protocol-relative rejection,
  and check is_array($urlparts) before accessing scheme to prevent
  array access on bool from parse_url returning false.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 7, 2026

@mambax7 mambax7 merged commit 184855d into XOOPS:master Apr 7, 2026
11 checks passed
@mambax7 mambax7 deleted the pre-release-review branch April 7, 2026 04:51
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