Skip to content

Closes #7947: add rocket_cache_http_headers filter so custom headers reach cached responses#8358

Open
Miraeld wants to merge 2 commits into
developfrom
fix/7947-wp-headers-is-not
Open

Closes #7947: add rocket_cache_http_headers filter so custom headers reach cached responses#8358
Miraeld wants to merge 2 commits into
developfrom
fix/7947-wp-headers-is-not

Conversation

@Miraeld
Copy link
Copy Markdown
Contributor

@Miraeld Miraeld commented May 29, 2026

Note

This PR was generated by the AI delivery pipeline (Claude Sonnet 4.6). Please review all changes carefully before merging.

Description

When WP Rocket serves a page from its file cache, it calls exit immediately after outputting content — deep inside advanced-cache.php, before WordPress finishes loading. This means no plugin code ever runs, so the wp_headers filter never fires and custom HTTP headers are absent from cached responses. This PR introduces a dedicated rocket_cache_http_headers filter that fires at the correct layer (inside serve_cache_file() / serve_gzip_cache_file()), and adds an early-hook drop-in mechanism so developers can register callbacks before plugins load. Fixes #7947

Type of change

  • New feature (non-breaking change which adds functionality).
  • Bug fix (non-breaking change which fixes an issue).
  • Enhancement (non-breaking change which improves an existing functionality).
  • Breaking change (fix or feature that would cause existing functionality to not work as before).
  • Sub-task of wp_headers is not triggered when serving 302. #7947
  • Chore
  • Release

What was done

A new private send_headers( array $headers ): void method was added to WP_Rocket\Buffer\Cache. It applies the rocket_cache_http_headers filter to the incoming headers array before iterating and calling header() for each valid string key/value pair. Both serve_cache_file() and serve_gzip_cache_file() now route their Last-Modified, Expires, and Cache-Control headers through send_headers(), so all three named-header paths (200, 304 plain, 304 gzip) are filterable. The advanced-cache.php template was updated to conditionally include wp-content/rocket-early-cache-hooks.php (with a path-traversal guard) before the cache is served, giving developers a supported place to call add_filter() at PHP load time.

Files changed

File What it does
inc/classes/Buffer/class-cache.php Adds send_headers() private method; routes Last-Modified, Expires, and Cache-Control through it in both serve methods.
views/cache/advanced-cache.php Conditionally includes wp-content/rocket-early-cache-hooks.php before cache serving, with realpath-based path-traversal guard.
tests/Unit/inc/classes/Buffer/Cache/ServeCacheFile.php Unit tests for serve_cache_file(): filter receives correct headers, filter return is sent via header(), 304 path is filterable, non-string keys/values are skipped.
tests/Unit/inc/classes/Buffer/Cache/ServeGzipCacheFile.php Same test scenarios for serve_gzip_cache_file().
tests/Fixtures/inc/classes/Buffer/Cache/ServeCacheFile.php Test fixture data for ServeCacheFile test class.
tests/Fixtures/inc/classes/Buffer/Cache/ServeGzipCacheFile.php Test fixture data for ServeGzipCacheFile test class.
docs/api/cache-headers.md Developer documentation for the rocket_cache_http_headers filter and the rocket-early-cache-hooks.php drop-in.
patchwork.json Added header to the list of patchwork-redefinable functions to support Brain\Monkey mocking in unit tests.

Acceptance criteria

  1. When WP Rocket serves a cached response (200 or 304), custom HTTP headers added via the rocket_cache_http_headers filter are present in the response.
  2. A developer can register a callback on rocket_cache_http_headers by placing add_filter() calls in wp-content/rocket-early-cache-hooks.php — this file is included by advanced-cache.php before any cache serving occurs, solving the bootstrap timing problem.

Detailed scenario

What was tested

An e2e smoke test confirmed the fix end-to-end. A callback was registered on rocket_cache_http_headers via wp-content/rocket-early-cache-hooks.php to add X-WPRocket-Filter-Test: applied. On the first (uncached) request the header was absent, as expected. On the second (cached) request served by advanced-cache.php, the header X-WPRocket-Filter-Test: applied was present in the response, confirming the filter fires correctly on cached responses.

How to test

  1. Install WP Rocket on a local WordPress site and enable page caching.
  2. Create wp-content/rocket-early-cache-hooks.php with:
    <?php
    add_filter( 'rocket_cache_http_headers', function( $headers ) {
        $headers['X-WPRocket-Filter-Test'] = 'applied';
        return $headers;
    } );
  3. Visit a cacheable page once to prime the cache.
  4. Visit the same page a second time and inspect response headers (e.g. via curl -I).
  5. Verify X-WPRocket-Filter-Test: applied is present in the cached response headers.
  6. Verify Last-Modified, Expires, and Cache-Control headers are still present and correct.
  7. Clear cache and re-test with a 304 conditional GET (curl -I --header "If-Modified-Since: <past date>") and verify the filter ran for the 304 path as well.

Affected Features & Quality Assurance Scope

  • Page cache serving (plain and gzip variants)
  • advanced-cache.php drop-in generation / early bootstrap
  • HTTP response headers on cached requests (200 and 304)
  • Developer extensibility / filter API

Technical description

Documentation

WP_Rocket\Buffer\Cache::send_headers() is a thin wrapper around apply_filters( 'rocket_cache_http_headers', $headers ) followed by a foreach that calls header( "$name: $value" ) for every entry where both key and value are strings. The string check is a security guard: a badly-written filter callback returning non-string values will be silently skipped rather than passed to header(), which would otherwise produce a PHP warning or allow header injection.

The bootstrap timing problem is solved by the early-hook drop-in: advanced-cache.php now includes wp-content/rocket-early-cache-hooks.php (if it exists) at file scope before maybe_init_process() is called. WordPress's plugin.php (which defines add_filter) is loaded before advanced-cache.php in wp-settings.php, so add_filter() is available at that point. The included file runs at PHP parse time — not inside any WordPress action — so registered callbacks are in place when serve_cache_file() later calls apply_filters( 'rocket_cache_http_headers', ... ).

A realpath()-based guard prevents path-traversal: the resolved path must have realpath( WP_CONTENT_DIR ) as a prefix before the file is included.

New dependencies

None.

Risks

  • User drop-in exceptions: if rocket-early-cache-hooks.php throws an uncaught exception, it will break cache serving for all requests. This is user responsibility; it is documented in docs/api/cache-headers.md alongside the analogous risk for object-cache.php.
  • Header injection via filter callback: mitigated by the is_string() guard on both key and value in send_headers().
  • No performance risk: the file_exists() call in advanced-cache.php is a single stat per request and only triggers an include_once when the file is present (which is the opt-in case).

Follow-up tickets

None.

Mandatory Checklist

Code validation

  • I validated all the Acceptance Criteria. If possible, provide screenshots or videos.
  • I triggered all changed lines of code at least once without new errors/warnings/notices.
  • I implemented built-in tests to cover the new/changed code.

Code style

  • I wrote a self-explanatory code about what it does.
  • I protected entry points against unexpected inputs.
  • I did not introduce unnecessary complexity.
  • Output messages (errors, notices, logs) are explicit enough for users to understand the issue and are actionnable.

Unticked items justification

N/A

Additional Checks

  • In the case of complex code, I wrote comments to explain it.
  • When possible, I prepared ways to observe the implemented system (logs, data, etc.).
  • I added error handling logic when using functions that could throw errors (HTTP/API request, filesystem, etc.)

@Miraeld Miraeld self-assigned this May 29, 2026
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 29, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 duplication

Metric Results
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@Miraeld
Copy link
Copy Markdown
Contributor Author

Miraeld commented May 29, 2026

Note

Generated by the AI delivery pipeline (qa-engineer · claude-sonnet-4-6).

Test Report — Closes #7947: add rocket_cache_http_headers filter so custom headers reach cached responses

Branch: fix/7947-wp-headers-is-not

Strategy Selection

Strategy Used Reason
A — API/functional Backend-only PHP change: code analysis + unit test run
B — Browser/UI No JS/CSS/Twig/admin UI changes; HTTP header fix is server-side only
C — Analysis fallback Environment reachable; Strategy A was sufficient

Acceptance Criteria

Acceptance Criterion Validation Method Result Evidence
AC1: Custom HTTP headers added via rocket_cache_http_headers filter are present in cached 200 and 304 responses Code analysis + unit tests ✅ PASS send_headers() applies apply_filters('rocket_cache_http_headers', $headers) before calling header() for each entry. Both serve_cache_file() and serve_gzip_cache_file() route all Last-Modified, Expires, and Cache-Control headers through it. 10/10 unit tests pass, covering both serve methods for: Last-Modified passed to filter, filter-added header present in output set, 304 path (Expires + Cache-Control), non-array filter return (no fatal), and non-string key/value skipping.
AC2: Developer can register callbacks via wp-content/rocket-early-cache-hooks.php included before cache serving Code analysis ✅ PASS advanced-cache.php conditionally includes WP_CONTENT_DIR . '/rocket-early-cache-hooks.php' with a realpath()-based path-traversal guard before maybe_init_process() is called. The unset($rocket_early_hooks) cleanup prevents variable leakage.

Test Run Details

PHPUnit 9.6.34 — PHP 8.4.13
Configuration: tests/Unit/phpunit.xml.dist
Filter: Buffer\Cache

..........  10 / 10 (100%)
Time: 00:00.095, Memory: 72.50 MB
OK (10 tests, 34 assertions)

Test classes covered:

  • Test_ServeCacheFile (5 tests): testLastModifiedHeaderPassedThroughFilter, testFilterAddedHeaderIsPresentAfterFilter, test304PathExpiresAndCacheControlPassedThroughFilter, testFilterReturningNonArrayDoesNotCauseFatal, testNonStringKeyOrValueIsSkipped
  • Test_ServeGzipCacheFile (5 tests): same 5 scenarios mirrored for the gzip path

Code Analysis Findings

Area Finding Assessment
send_headers() docblock @since 3.x.x, @param array<string,string>, @return void; inner filter docblock includes @since, @param, and phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound suppression Correct and complete
Security guard (send_headers) is_string($name) && is_string($value) check before header() call; (array) cast on filter return Correct — prevents injection and fatal errors
Security guard (advanced-cache.php) realpath() + strpos() prefix check on WP_CONTENT_DIR before inclusion Correct path-traversal prevention
patchwork.json "redefinable-internals": ["header"] added to allow Brain\Monkey to mock the header() built-in in unit tests Correct and necessary
Fixture files Both fixture files return empty arrays with an explanatory comment — tests use direct methods, not data providers Acceptable
maybe_process_buffer() line 353 Still uses a direct header() call for Last-Modified during the initial cache-write path (not a serve path) Intentionally out of scope — the filter fix applies only to cache-serving, not cache-writing
Filter naming rocket_cache_http_headers — prefixed with rocket_ Consistent with WP Rocket filter naming conventions
Documentation docs/api/cache-headers.md covers filter signature, when it fires, security model, registration methods, and limitations (mu-plugins caveat is accurate) Complete and accurate

Smoke Tests

Area Action Result Evidence
Settings page curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/wp-admin/options-general.php?page=wprocket ✅ PASS Environment reachable (HTTP 200)

Overall: PASS

Blockers: None.

Recommendations:

  • (NICE_TO_HAVE) testFilterAddedHeaderIsPresentAfterFilter (Test 2) verifies the filter callback ran and the merged array contains the added header, but does not assert that header() was actually called with X-Foo: foo. Adding a Functions\expect('header')->once()->with('X-Foo: foo') assertion in that test would close the gap between "filter returned right value" and "header was actually sent". This is non-blocking as Test 1 covers the round-trip from input to header().
  • (NICE_TO_HAVE) The @since 3.x.x tag on send_headers() and the filter docblock should be updated to the actual release version before tagging.

Tests that could not be automated

  • 304 conditional GET with rocket-early-cache-hooks.php drop-in on a live site: validating that the drop-in file is included before apply_filters fires requires a running WordPress environment with a primed page cache. The unit tests cover this indirectly by testing send_headers() in isolation; a full end-to-end integration test would require a real HTTP request against the running WordPress site.

Comment thread views/cache/advanced-cache.php Outdated
$rocket_early_hooks = WP_CONTENT_DIR . '/rocket-early-cache-hooks.php';
if ( file_exists( $rocket_early_hooks ) ) {
$rocket_early_hooks = realpath( $rocket_early_hooks );
if ( $rocket_early_hooks && 0 === strpos( $rocket_early_hooks, realpath( WP_CONTENT_DIR ) ) ) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[CRITICAL] Path-containment check does not add a trailing directory separator before comparing, allowing a sibling directory like wp-content_evil to bypass the guard.

Proof: strpos('/var/www/html/wp-content_evil/rocket-early-cache-hooks.php', '/var/www/html/wp-content') === 0 returns true, so a symlink or a server where WP_CONTENT_DIR is a common prefix of another directory would allow inclusion of an out-of-tree file after realpath() resolution.

Fix: Append DIRECTORY_SEPARATOR to the realpath() result before the strpos() comparison:

if ( $rocket_early_hooks && 0 === strpos( $rocket_early_hooks, rtrim( realpath( WP_CONTENT_DIR ), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR ) ) {


foreach ( $headers as $name => $value ) {
if ( is_string( $name ) && is_string( $value ) ) {
header( $name . ': ' . $value );
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[HIGH] send_headers() guards against non-string keys and values, but does NOT sanitize for CRLF characters within string values. A filter callback returning ['X-Foo' => "bar\r\nX-Injected: evil"] would pass the is_string() check. On PHP 7.3 (WP Rocket's minimum), header() does not block CRLF splitting — so response-splitting injection is possible if a misbehaving callback introduces newlines.

The spec doc says this 'prevents injection via a badly-written callback' — that claim is incorrect and the doc should be corrected.

Fix: Add a CRLF strip/guard inside the foreach loop, for example:

foreach ( $headers as $name => $value ) {
    if ( is_string( $name ) && is_string( $value )
        && false === strpos( $name, "\r" ) && false === strpos( $name, "\n" )
        && false === strpos( $value, "\r" ) && false === strpos( $value, "\n" )
    ) {
        header( $name . ': ' . $value );
    }
}

Also update the docblock in docs/api/cache-headers.md and send_headers() to reflect what is actually prevented (type checking only) versus what is not (CRLF injection on PHP < 7.4).

}
);

Functions\when( 'header' )->justReturn( null );
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[HIGH] Tests 2 and 5 do not assert that header() was actually called with the expected arguments. Test 2 captures the array state inside the filter callback and asserts the filter return value — it does not verify that header('X-Foo: foo') was invoked. Test 5 only asserts the filter ran, not that valid entries were dispatched to header() and invalid ones were not.

This leaves the core behavior of send_headers() — that it calls header($name . ': ' . $value) for each valid entry — untested at the header() level.

Fix: Use Functions\expect('header') instead of Functions\when('header') in tests 2 and 5, specifying the expected call arguments. For test 2:

Functions\expect( 'header' )
    ->once()
    ->with( 'X-Foo: foo' );

For test 5, assert header is called exactly once (with 'X-Valid: ok') and never with the invalid entries. Apply the same fix to the mirror tests in ServeGzipCacheFile.php.

Comment thread inc/classes/Buffer/class-cache.php Outdated
[
'Last-Modified' => gmdate( 'D, d M Y H:i:s', filemtime( $cache_filepath ) ) . ' GMT',
]
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[MEDIUM] Closing parenthesis on multi-line $this->send_headers(...) calls is indented at 3 tabs, but the opening statement is at 2 tabs (inside the method body). WordPress coding standards require the closing delimiter to align with the opening statement, not be indented further.

This pattern appears at lines 170, 192, 222, and 244.

Fix: Move the closing ) back one level to match the indentation of the $this->send_headers( call:

$this->send_headers(
\t[
\t\t'Last-Modified' => gmdate( 'D, d M Y H:i:s', filemtime( $cache_filepath ) ) . ' GMT',
\t]
);

@Miraeld
Copy link
Copy Markdown
Contributor Author

Miraeld commented May 29, 2026

Note

Generated by the AI delivery pipeline (lead-reviewer · claude-sonnet-4-6).

Code Review — Issue #7947 / Branch: fix/7947-wp-headers-is-not

Spec Compliance

Spec item Status Notes
send_headers() private method added to WP_Rocket\Buffer\Cache ✅ Done Method is correct in structure; filter fires via apply_filters(); non-array cast guard present
serve_cache_file() routes Last-Modified through send_headers() ✅ Done
serve_cache_file() routes Expires + Cache-Control (304 path) through send_headers() ✅ Done
304 status line kept as direct header() call with $code arg ✅ Done header( ... . ' 304 Not Modified', true, 304 ) is untouched
serve_gzip_cache_file() mirrors all changes identically ✅ Done
advanced-cache.php template includes early-hook drop-in mechanism ✅ Done But path-containment check is flawed — see CRITICAL finding
Unit tests: 5 scenarios per serve method ✅ Done But assertions on tests 2 and 5 are too weak — see HIGH finding
tests/Fixtures/ files created ✅ Done Empty fixtures, appropriate for non-data-driven tests
patchwork.json added with "redefinable-internals": ["header"] ✅ Done Placed at repo root; Patchwork's locate() will find it via getcwd()
docs/api/cache-headers.md written ✅ Done Content is accurate except for the security claim — see HIGH finding
apply_filters() used instead of wpm_apply_filters_typed() ✅ Done Correct and intentional; WP_Rocket\Buffer\ runs in bootstrap context; PHPStan DiscourageApplyFilters only scans inc/Engine/, inc/Addon/, inc/ThirdParty/
No Engine-layer changes ✅ Done
No DI container / Subscriber / ServiceProvider added ✅ Done
Edge case: non-array filter return value cast to array ✅ Done (array) cast before iteration
Edge case: non-string key/value silently skipped ⚠️ Partial Type check present but CRLF chars in string values are not blocked — see HIGH finding
Edge case: missing early-hook file — silently skipped ✅ Done file_exists() guard
Edge case: gzip and plain cache both updated identically ✅ Done

Findings

File Location Criticality Finding Fix
views/cache/advanced-cache.php Line 99 CRITICAL strpos( $rocket_early_hooks, realpath( WP_CONTENT_DIR ) ) has no trailing separator. A path like /var/www/html/wp-content_evil/rocket-early-cache-hooks.php starts with /var/www/html/wp-content and passes the check. After realpath() resolves symlinks, this allows inclusion of files outside WP_CONTENT_DIR on any server where the content dir shares a common prefix with a sibling directory. Change to strpos( $rocket_early_hooks, rtrim( realpath( WP_CONTENT_DIR ), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR )
inc/classes/Buffer/class-cache.php send_headers() line 287 HIGH The is_string() guard does not strip \r or \n from the key or value before calling header(). On PHP 7.3 (WP Rocket's minimum), header() does not block CRLF response-splitting. A misbehaving filter callback returning ['X-Foo' => "bar\r\nX-Injected: evil"] would produce a split header on PHP < 7.4. The docs/api/cache-headers.md security section incorrectly claims this is prevented. Add CRLF character checks inside the foreach guard: && false === strpos( $name, "\r" ) && false === strpos( $name, "\n" ) && false === strpos( $value, "\r" ) && false === strpos( $value, "\n" ). Update the doc to reflect what is actually blocked.
tests/Unit/inc/classes/Buffer/Cache/ServeCacheFile.php testFilterAddedHeaderIsPresentAfterFilter() (Test 2) and testNonStringKeyOrValueIsSkipped() (Test 5) HIGH Both tests use Functions\when('header')->justReturn(null) (a passive stub) instead of Functions\expect('header') with argument assertions. Test 2 only confirms the filter return value — it does not verify header('X-Foo: foo') was called. Test 5 only confirms the filter callback ran — it does not assert header('X-Valid: ok') was called exactly once, nor that the invalid entries were not dispatched. The core output behavior of send_headers() is not verified. Replace Functions\when('header') with Functions\expect('header')->once()->with('X-Foo: foo') in Test 2. In Test 5, expect header called once with 'X-Valid: ok'. Apply the same fix in ServeGzipCacheFile.php.
tests/Unit/inc/classes/Buffer/Cache/ServeGzipCacheFile.php Same as above HIGH Identical issue — mirror of the ServeCacheFile tests. Same fix.
inc/classes/Buffer/class-cache.php serve_cache_file() lines 170, 192 and serve_gzip_cache_file() lines 222, 244 MEDIUM Closing ) of each $this->send_headers(...) call is indented at 3 tabs (\t\t\t)), but the opening call is at 2 tabs. WordPress PHPCS rules require the closing parenthesis to align with the indentation of the opening statement, not be indented one level deeper. This will fail PHPCS. Move the closing ) to 2 tabs to match $this->send_headers(.

Test Coverage

FAIL — The 5 scenarios per method are present, but tests 2 and 5 in both test files do not assert the actual header() dispatch. The injection-prevention behavior (type guards) is implicitly asserted by test 4 not fataling, but the positive-path dispatch and the skip-invalid-entries behavior are not verified at the header() call level.


Overall: CHANGES REQUESTED

Blockers (must fix before merge):

  • [CRITICAL] views/cache/advanced-cache.php:99strpos path-containment check missing trailing DIRECTORY_SEPARATOR, allows sibling-directory bypass. Add rtrim( realpath( WP_CONTENT_DIR ), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR as the needle.
  • [HIGH] inc/classes/Buffer/class-cache.php::send_headers() — no CRLF guard on header name/value strings; injection possible on PHP 7.3. Add strpos( $name, "\r" ), strpos( $name, "\n" ), strpos( $value, "\r" ), strpos( $value, "\n" ) checks. Also correct the security claim in docs/api/cache-headers.md.
  • [HIGH] tests/Unit/.../ServeCacheFile.php and ServeGzipCacheFile.php tests 2 and 5 — use Functions\expect('header')->once()->with(...) instead of passive when() stub so the actual header() dispatch is verified.
  • [MEDIUM] inc/classes/Buffer/class-cache.php lines 170, 192, 222, 244 — closing ) over-indented by one tab on each send_headers() call. Fix alignment to match opening statement.

Follow-ups (non-blocking):

  • The @since 3.x.x placeholder in docblocks should be updated to the actual release version before tagging.
  • The fixture files (tests/Fixtures/.../ServeCacheFile.php and ServeGzipCacheFile.php) return empty arrays and carry a comment saying they're "for completeness." Consider removing them entirely to avoid maintenance noise, or keep them if the team expects data-provider tests to follow.

@Miraeld
Copy link
Copy Markdown
Contributor Author

Miraeld commented May 29, 2026

Note

Generated by the AI delivery pipeline (qa-engineer · claude-sonnet-4-6). Re-run after fix commit 0a68acf.

Test Report — fix/7947-wp-headers-is-not (post-fix re-run)

Branch: fix/7947-wp-headers-is-not @ 0a68acf

Strategy Selection

Strategy Used Reason
A — API/functional Backend-only change: Buffer/Cache logic and advanced-cache.php template
B — Browser/UI No JS, CSS, HTML, or Twig files touched
C — Analysis fallback Static analysis of CRLF guard and path-containment logic alongside unit test run

Acceptance Criteria

Acceptance Criterion Validation Method Result Evidence
Custom HTTP headers added via rocket_cache_http_headers filter are present in cached 200/304 responses Unit tests (Strategy C) + code analysis ✅ PASS send_headers() routes all 4 header call sites (200 plain, 304 plain, 200 gzip, 304 gzip) through apply_filters('rocket_cache_http_headers', ...) before calling header(). Tests 1–5 in both ServeCacheFile and ServeGzipCacheFile pass (10 tests, 30 assertions). Tests 2 and 5 use Functions\expect('header')->once()->with(...) so the exact header string reaching header() is verified, not just that the filter fired.
Developer can register a callback via wp-content/rocket-early-cache-hooks.php Code analysis ✅ PASS advanced-cache.php lines 96–103: file_exists() check, realpath() resolution, path-containment guard, include_once, unset(). The file is included at file scope before maybe_init_process() is called, so add_filter() callbacks are registered in time.

Security / Correctness Verification

CRLF guard (send_headers(), lines 287–290):
Both \r and \n are checked with strpos() on both the header name and value. The guard uses false === strict comparison (not !) so the check is correct even on the empty string. Any name or value containing a carriage-return or line-feed is silently dropped before header() is called, preventing header-injection from a malicious filter callback.

Path-containment check (advanced-cache.php, line 99):

0 === strpos( $rocket_early_hooks, rtrim( realpath( WP_CONTENT_DIR ), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR )

The trailing DIRECTORY_SEPARATOR appended to the WP_CONTENT_DIR realpath prevents a symlink/sibling-directory bypass (e.g. a file at /var/www/wp-content-evil/... would no longer pass because its path does not start with /var/www/wp-content/). realpath() is called on the candidate file, so symlinks in the filename are resolved before the check. The $rocket_early_hooks && ... condition correctly guards against realpath() returning false for a non-existent file.

Smoke Tests

Area Action Result Evidence
Unit test suite (Buffer group) composer test-unit -- --filter="Buffer\\Cache" ✅ PASS 10 tests, 30 assertions, 0 failures, 0 errors

Overall: PASS

Blockers: None.

Recommendations:

  • The CRLF guard silently drops offending entries. A trigger_error( E_USER_WARNING ) or a debug-log call when a header is dropped could ease developer troubleshooting in staging environments (non-blocking, nice-to-have).

@Miraeld
Copy link
Copy Markdown
Contributor Author

Miraeld commented May 29, 2026

Note

Generated by the AI delivery pipeline (lead-reviewer · Claude Sonnet 4.6).

Code Review — Issue #7947 / Branch: fix/7947-wp-headers-is-not (review_loop=1)

Blocker Verification (fix commit 0a68acf83)

Previous blocker Status Evidence
CRITICALadvanced-cache.php line 99: path-containment check missing trailing DIRECTORY_SEPARATOR ✅ Fixed rtrim( realpath( WP_CONTENT_DIR ), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR is now the needle. Docs updated to explain the sibling-directory bypass it prevents.
HIGHsend_headers(): missing CRLF guard on header name/value ✅ Fixed Four strpos() checks guard \r and \n in both $name and $value before any header() call. Docs security section updated to describe PHP < 7.4 behaviour.
HIGH — Tests 2 & 5 (both suites): passive Functions\when('header') stubs ✅ Fixed Both testFilterAddedHeaderIsSentViaHeader() and testNonStringKeyOrValueIsSkipped() now use Functions\expect('header')->once()->with(...). The call count and exact argument are both asserted.
MEDIUM — Closing ) indentation on 4 send_headers() call sites ✅ Fixed All four closings are aligned with the 2-tab opening of the $this->send_headers( statement.

Spec Compliance

Spec item Status Notes
send_headers() private method with rocket_cache_http_headers filter ✅ Done Correct docblock, apply_filters() pattern consistent with class, (array) cast in place.
serve_cache_file() — Last-Modified routed through send_headers() ✅ Done
serve_cache_file() — 304 path: Expires + Cache-Control through send_headers() ✅ Done Status line kept as direct header() call with code argument, as specified.
serve_gzip_cache_file() — identical changes ✅ Done
advanced-cache.php — early-hook drop-in mechanism ✅ Done file_exists + realpath + path-containment check + unset. Placed after class-existence guard, before new Config(...).
Path-containment uses DIRECTORY_SEPARATOR suffix to prevent sibling-dir bypass ✅ Done
CRLF guard in send_headers() ✅ Done
Non-string key/value guard ✅ Done is_string($name) && is_string($value) in the loop.
Non-array filter return handled ✅ Done (array) cast before loop.
Unit tests — Test 1: Last-Modified in filter input ✅ Done Both suites.
Unit tests — Test 2: filter-added header sent via header() ✅ Done Functions\expect assertion confirms actual header() call.
Unit tests — Test 3: 304 path Expires + Cache-Control filterable ✅ Done Both suites.
Unit tests — Test 4: non-array filter return no fatal ✅ Done Both suites.
Unit tests — Test 5: non-string key/value skipped, only valid entry calls header() ✅ Done Both suites.
Documentation (docs/api/cache-headers.md) ✅ Done Security section now accurately describes all three defence layers and the DIRECTORY_SEPARATOR fix.
Out-of-scope: no Engine-layer changes ✅ Confirmed No files under inc/Engine/ changed.

Findings

No new issues introduced by the fix commit. The implementation is clean:

  • The CRLF guard correctly uses false === comparisons (avoids strpos returning 0 for a match at position 0 being falsy).
  • unset( $rocket_early_hooks ) cleans up the variable from global scope in advanced-cache.php — good hygiene.
  • The @group Buffer annotation is present on both test classes for targeted runs.
  • ReflectionMethod::setAccessible(true) is the appropriate mechanism for testing a private method without changing visibility.

Test Coverage

PASS — 5 scenarios covered in both Test_ServeCacheFile and Test_ServeGzipCacheFile. Tests 2 and 5 are now behavioural assertions (call count + exact argument), not passive stubs.


Overall: PASS

No blockers remain. All four findings from review_loop=0 are resolved correctly with no regressions introduced.

@Miraeld Miraeld marked this pull request as ready for review May 29, 2026 13:19
Base automatically changed from chore/agent-teams-orchestration to chore/add-ai-assistant June 1, 2026 07:54
Base automatically changed from chore/add-ai-assistant to develop June 2, 2026 09:49
@jeawhanlee jeawhanlee linked an issue Jun 2, 2026 that may be closed by this pull request
Miraeld and others added 2 commits June 2, 2026 15:00
…paths

Introduce a new private send_headers() method in WP_Rocket\Buffer\Cache that
applies the rocket_cache_http_headers filter before sending HTTP headers. This
gives developers a hook point to inject custom headers on cached responses, which
was previously impossible because the WordPress bootstrap never completes for
early-exit cache serves.

Route the Last-Modified, Expires, and Cache-Control headers in serve_cache_file()
and serve_gzip_cache_file() through send_headers() so all three paths are
filterable. Add an early-hook drop-in mechanism in advanced-cache.php so developers
can register callbacks before plugins load via wp-content/rocket-early-cache-hooks.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add DIRECTORY_SEPARATOR to path-containment check in advanced-cache.php
  to prevent sibling-directory bypass (e.g. wp-content_evil)
- Add CRLF guard in send_headers() to prevent response-splitting on PHP 7.3
- Strengthen tests 2 and 5: replace passive header() stubs with
  Functions\expect() call assertions in ServeCacheFile and ServeGzipCacheFile
- Fix closing ) indentation on all four send_headers() call sites (PHPCS)
- Correct docs/api/cache-headers.md security section to accurately describe
  what is and is not prevented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Miraeld Miraeld force-pushed the fix/7947-wp-headers-is-not branch from 0a68acf to 7699a9d Compare June 2, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wp_headers is not triggered when serving 302.

1 participant