Unverified Commit f0cd52cd authored by Alex Pott's avatar Alex Pott
Browse files

fix: #2844620 Automatically split cache debug headers into multiple lines when they exceed 8k

By: fgm
By: regilero
By: vaza18
By: charginghawk
By: abrar_arshad
By: mxr576
By: smustgrave
By: rgpublic
By: samlerner
By: tunic
By: b_sharpe
By: scott_euser
By: prudloff
By: quietone
By: erickbj
By: godotislate
By: wim leers
By: mr.baileys
By: bappa.sarkar
By: Steven McCoy
By: akz
By: luke.leber
By: nicxvan
By: benstallings
By: oily
(cherry picked from commit d38a4f4e)
parent ae286df7
Loading
Loading
Loading
Loading
Loading
+50 −9
Original line number Diff line number Diff line
@@ -11,17 +11,39 @@
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Response subscriber to handle finished responses.
 */
class FinishResponseSubscriber implements EventSubscriberInterface {

  /**
   * The character length limit for Drupal cache headers.
   *
   * This is used for the 'X-Drupal-Cache-Tags' and 'X-Drupal-Cache-Contexts'
   * headers. Apache has a hardcoded limit of 8190 bytes for response header
   * line length, so this value is set slightly below that.
   */
  protected const int RESPONSE_HEADER_LINE_MAX_LENGTH = 8000;

  /**
   * Separator between cache tags and cache context in respective debug headers.
   *
   * When setting multiple response headers with the same name, the value for
   * the header name is meant to be interpreted as the concatenation,
   * comma-separated, of all the header values with that name. Since the headers
   * here are meant to be used for debug only, the values are space-separated
   * for legibility instead.
   *
   * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-field-lines-and-combined-fi
   */
  protected const string RESPONSE_HEADER_CACHE_ITEM_SEPARATOR = ' ';

  /**
   * A config object for the system performance configuration.
   *
@@ -120,15 +142,11 @@ public function onRespond(ResponseEvent $event) {
    }

    if ($this->debugCacheabilityHeaders) {
      // Expose the cache contexts and cache tags associated with this page in a
      // X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags header respectively.
      // Expose the cache contexts and cache tags associated with this page in
      // X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags headers respectively.
      $response_cacheability = $response->getCacheableMetadata();
      $cache_tags = $response_cacheability->getCacheTags();
      sort($cache_tags);
      $response->headers->set('X-Drupal-Cache-Tags', implode(' ', $cache_tags));
      $cache_contexts = $this->cacheContextsManager->optimizeTokens($response_cacheability->getCacheContexts());
      sort($cache_contexts);
      $response->headers->set('X-Drupal-Cache-Contexts', implode(' ', $cache_contexts));
      $this->addDebugCacheHeaders('X-Drupal-Cache-Tags', $response_cacheability->getCacheTags(), $response);
      $this->addDebugCacheHeaders('X-Drupal-Cache-Contexts', $this->cacheContextsManager->optimizeTokens($response_cacheability->getCacheContexts()), $response);
      $max_age_message = $response_cacheability->getCacheMaxAge();
      if ($max_age_message === 0) {
        $max_age_message = '0 (Uncacheable)';
@@ -281,6 +299,29 @@ protected function setExpiresNoCache(Response $response) {
    $response->setExpires(\DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 UTC'));
  }

  /**
   * Adds cache metadata information as headers in the response.
   *
   * If a header exceeds the maximum response header length, the data will be
   * split across multiple header lines with the same header name. By default,
   * Apache uses the header merge strategy that merges and glues all the lines
   * into one with ', ' separating them. Nginx will send separate headers lines
   * with the same name.
   *
   * @param string $header
   *   The response header name.
   * @param list<string> $values
   *   The list of either cache tags or contexts.
   * @param \Symfony\Component\HttpFoundation\Response $response
   *   The response object.
   */
  protected function addDebugCacheHeaders(string $header, array $values, Response $response): void {
    sort($values);
    $values_as_string = implode(static::RESPONSE_HEADER_CACHE_ITEM_SEPARATOR, $values);
    $headers = explode("\n", wordwrap($values_as_string, static::RESPONSE_HEADER_LINE_MAX_LENGTH));
    $response->headers->set($header, $headers);
  }

  /**
   * Registers the methods in this class that should be listeners.
   *
+5 −0
Original line number Diff line number Diff line
name: 'Test HTTP response debug cacheability headers'
type: module
description: 'Provides routes for testing HTTP response debug cacheability headers'
package: Testing
version: VERSION
+15 −0
Original line number Diff line number Diff line
http_response_debug_cacheability_headers_test.test_cache_contexts_headers:
  path: '/test-cache-contexts-headers'
  defaults:
    _title: 'Test debug cache contexts headers'
    _controller: Drupal\http_response_debug_cacheability_headers_test\Controller\TestResponseController::testCacheContextsHeaders
  requirements:
    _access: 'TRUE'

http_response_debug_cacheability_headers_test.test_cache_tags_headers:
  path: '/test-cache-tags-headers'
  defaults:
    _title: 'Test debug cache tags headers'
    _controller: Drupal\http_response_debug_cacheability_headers_test\Controller\TestResponseController::testCacheTagsHeaders
  requirements:
    _access: 'TRUE'
+67 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\http_response_debug_cacheability_headers_test\Controller;

use Drupal\Core\Controller\ControllerBase;

/**
 * Provides responses for testing debug cacheability headers in HTTP responses.
 *
 * Apache has a response header line limit of 8190 bytes. Complex applications
 * can have a lot of cache tags (and cache contexts, though less likely) that
 * bubble to response and are sent as HTTP response headers when the container
 * parameter http.response.debug_cacheability_headers is set to TRUE. To solve
 * this, the debug cache headers are split into multiple lines with the same
 * header name.
 *
 * Nginx has a limit on the total HTTP response header size, including all
 * lines, does not have limits per header line, so these responses will not
 * cause server errors even if the lines are not split.
 */
class TestResponseController extends ControllerBase {

  /**
   * Provides a render array response that has a large number of cache contexts.
   *
   * @return array
   *   Render array.
   */
  public function testCacheContextsHeaders(): array {
    // Create multiple cache contexts that add up to more than 8k bytes.
    for ($i = 0; $i < 700; $i++) {
      $contexts[] = 'url.query_args:' . str_pad("$i", 4, '0', STR_PAD_LEFT);
    }
    $contexts_length = strlen(implode(' ', $contexts));

    return [
      '#markup' => 'This is a test of a list of cache contexts debug headers that exceed ' . $contexts_length . ' bytes in total.',
      '#cache' => [
        'contexts' => $contexts,
      ],
    ];
  }

  /**
   * Provides a render array response that has a large number of cache tags.
   *
   * @return array
   *   Render array.
   */
  public function testCacheTagsHeaders(): array {
    // Create multiple cache tags that add up to more than 8k bytes.
    for ($i = 0; $i < 800; $i++) {
      $tags[] = 'cache-tag:' . str_pad("$i", 5, '0', STR_PAD_LEFT);
    }
    $tags_length = strlen(implode(' ', $tags));

    return [
      '#markup' => 'This is a test of a list of cache tags debug headers that exceed ' . $tags_length . ' bytes in total.',
      '#cache' => [
        'tags' => $tags,
      ],
    ];
  }

}
+71 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\KernelTests\Core\EventSubscriber;

use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests that debug cacheability header lines do not exceed Apache limit.
 */
#[Group('EventSubscriber')]
#[RunTestsInSeparateProcesses]
class HttpResponseDebugCacheabilityHeadersTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['http_response_debug_cacheability_headers_test'];

  /**
   * {@inheritdoc}
   */
  public function register(ContainerBuilder $container): void {
    parent::register($container);
    $container->setParameter('http.response.debug_cacheability_headers', TRUE);
  }

  /**
   * Tests that cache debug headers do not error from exceeding line limits.
   */
  public function testCacheDebugHeadersLineLength(): void {
    $assertSession = $this->assertSession();
    $this->drupalGet('test-cache-contexts-headers');
    $assertSession->statusCodeEquals(200);
    $assertSession->addressEquals('test-cache-contexts-headers');
    $headers = $this->getSession()->getResponseHeaders();
    $this->assertArrayHasKey('x-drupal-cache-contexts', $headers);
    $context_lines = (array) $headers['x-drupal-cache-contexts'];
    // The payload is engineered to exceed the Apache line limit, so the header
    // must have been split across multiple lines, and each individual line must
    // stay under the 8190-byte limit that would otherwise trigger a 500.
    $this->assertGreaterThan(1, count($context_lines));
    foreach ($context_lines as $line) {
      $this->assertLessThanOrEqual(8190, strlen($line));
    }
    // Merge multiple cache contexts headers together if needed.
    $contexts = implode(' ', $context_lines);
    $this->assertStringContainsString('url.query_args:0000', $contexts);
    $this->assertStringContainsString('url.query_args:0699', $contexts);

    $this->drupalGet('test-cache-tags-headers');
    $assertSession->statusCodeEquals(200);
    $assertSession->addressEquals('test-cache-tags-headers');
    $headers = $this->getSession()->getResponseHeaders();
    $this->assertArrayHasKey('x-drupal-cache-tags', $headers);
    $tag_lines = (array) $headers['x-drupal-cache-tags'];
    $this->assertGreaterThan(1, count($tag_lines));
    foreach ($tag_lines as $line) {
      $this->assertLessThanOrEqual(8190, strlen($line));
    }
    // Merge multiple cache tags headers together if needed.
    $tags = implode(' ', $tag_lines);
    $this->assertStringContainsString('cache-tag:00000', $tags);
    $this->assertStringContainsString('cache-tag:00799', $tags);
  }

}
Loading