Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ This plugin offers a caching layer for optimal performance. It will be used if y

The default TTL for all cache objects is 5 minutes, but it can be [configured per query or request](../extending/query.md#cache_ttl-intnullcallable). Error responses are cached for 30 seconds to avoid overwhelming the remote data source under error conditions. Multiple requests for the same data within a single page load will be deduplicated even if the requests are not cacheable.

### Cache isolation and custom request headers

The response cache is shared across queries. When the site uses a persistent object cache, it is also shared across requests and users. Cache entries distinguish requests by their method, URI, body, and a configured list of request headers. `Authorization` and `Cache-Control` are included in that list by default, but arbitrary request headers are not included automatically.

**Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data.

Use each query's [`cache_key_request_headers`](../extending/query.md#cache_key_request_headers-array) configuration to add every custom header that can affect the authorized or returned data. Headers defined by a data source are not added to cache keys automatically. The built-in defaults cannot be removed.

## Technical concepts

If you want to understand the internals of Remote Data Blocks so that you can write code to extend its functionality, head over to the [extending guide](../extending/index.md).
Expand Down
2 changes: 2 additions & 0 deletions docs/extending/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ An associative array of headers that will be sent with each HTTP request. Querie

When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage.

**Security warning:** Defining a custom authentication, authorization, tenancy, or response-varying header on a data source does not automatically include it in cache keys. Add the header name to the [`cache_key_request_headers`](query.md#cache_key_request_headers-array) configuration of every query that uses it. Otherwise, requests with different header values can share cached responses and potentially expose protected data across requests or users when a persistent object cache is enabled.

### Next steps

After defining a data source in code, you can use it in a [query](query.md) to define how data is retrieved.
18 changes: 18 additions & 0 deletions docs/extending/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ The `request_headers` property defines the request headers for the query. It can
},
```

### cache_key_request_headers: array

A static list of additional request header names whose values will be included in the object cache key for this query. `Authorization` and `Cache-Control` are always included by default, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored.

```php
'cache_key_request_headers' => [ 'X-Request-Scope' ],
```

**Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled.

If you implement `HttpQueryInterface` directly, implement `get_cache_key_request_headers()`. Return an empty array to use only the built-in `Authorization` and `Cache-Control` defaults, or return additional header names for that query:

```php
public function get_cache_key_request_headers(): array {
return [ 'X-Api-Key' ];
}
```

### request_body: array|callable

The `request_body` property defines the request body for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will not have a request body.
Expand Down
5,772 changes: 2,981 additions & 2,791 deletions docs/for-ai.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function register_github_markdown_remote_data_block(): void {
$get_file_as_html_query = [
'display_name' => 'Get GitHub Markdown file as HTML',
'data_source' => $github_data_source,
'cache_key_request_headers' => [ 'Accept' ],
// Provide a callable (closure) to dynamically generate the endpoint using
// variables in the outer scope and the input variables.
'endpoint' => function ( array $input_variables ) use ( $repo_owner, $repo_name, $repo_ref ): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ function register_basic_rest_api_remote_data_block_from_uuid(): void {
// Get item query: Fetch one record by ID.
$get_item_query = [
'data_source' => $api_data_source,
'cache_key_request_headers' => [
// TODO: Include every custom header from the UI-configured data source that
// can affect authentication, authorization, tenancy, or the returned data.
// 'X-API-Key',
],
// Provide a callable (closure) to dynamically generate the endpoint using
// the base endpoint from the data source and the input variables.
'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string {
Expand Down Expand Up @@ -70,6 +75,11 @@ function register_basic_rest_api_remote_data_block_from_uuid(): void {
// List items query: Fetch multiple records with pagination and search.
$list_items_query = [
'data_source' => $api_data_source,
'cache_key_request_headers' => [
// TODO: Include every custom header from the UI-configured data source that
// can affect authentication, authorization, tenancy, or the returned data.
// 'X-API-Key',
],
// Provide a callable (closure) to dynamically generate the endpoint using
// the base endpoint from the data source and the input variables.
'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string {
Expand Down
6 changes: 6 additions & 0 deletions example/templates/rest-api-block/rest-api-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ function register_basic_rest_api_remote_data_block(): void {
$get_item_query = [
'display_name' => 'Get item by ID',
'data_source' => $api_data_source,
// Include every custom request header above that can affect authentication,
// authorization, tenancy, or the returned data.
'cache_key_request_headers' => [ 'X-API-Key' ],
// Provide a callable (closure) to dynamically generate the endpoint using
// the base endpoint from the data source and the input variables.
'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string {
Expand Down Expand Up @@ -79,6 +82,9 @@ function register_basic_rest_api_remote_data_block(): void {
$list_items_query = [
'display_name' => 'List items',
'data_source' => $api_data_source,
// Include every custom request header above that can affect authentication,
// authorization, tenancy, or the returned data.
'cache_key_request_headers' => [ 'X-API-Key' ],
// Provide a callable (closure) to dynamically generate the endpoint using
// the base endpoint from the data source and the input variables.
'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string {
Expand Down
9 changes: 9 additions & 0 deletions inc/Config/Query/HttpQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public function execute_batch( array $array_of_input_variables ): array|WP_Error
return $query_runner->execute_batch( $this, $array_of_input_variables );
}

/**
* Get the request header names whose values should be included in cache keys.
*
* @return array<string> Request header names included in cache keys.
*/
public function get_cache_key_request_headers(): array {
return $this->config['cache_key_request_headers'] ?? [];
}

/**
* Define the cache object TTL for the current query execution's responses:
* - Return a positive integer to set a custom TTL in seconds.
Expand Down
8 changes: 8 additions & 0 deletions inc/Config/Query/HttpQueryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
interface HttpQueryInterface extends QueryInterface {
public function get_data_source(): HttpDataSourceInterface;
public function get_cache_ttl( array $input_variables ): null|int;

/**
* Get the request header names whose values should be included in cache keys.
*
* @return array<string> Request header names included in cache keys.
*/
public function get_cache_key_request_headers(): array;

public function get_endpoint( array $input_variables ): string;
public function get_request_method(): string;
public function get_request_headers( array $input_variables ): array|WP_Error;
Expand Down
8 changes: 7 additions & 1 deletion inc/Config/QueryRunner/QueryRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use GuzzleHttp\RequestOptions;
use RemoteDataBlocks\Config\Query\HttpQueryInterface;
use RemoteDataBlocks\Editor\DataBinding\Pagination;
use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders;
use RemoteDataBlocks\HttpClient\HttpClient;
use RemoteDataBlocks\HttpClient\RdbCacheStrategy;
use WP_Error;
Expand Down Expand Up @@ -57,6 +58,9 @@ protected function get_request_details( HttpQueryInterface $query, array $input_
$body = $query->get_request_body( $input_variables );
$endpoint = $query->get_endpoint( $input_variables );
$cache_ttl = $query->get_cache_ttl( $input_variables );
$cache_key_request_headers = CacheKeyRequestHeaders::merge(
$query->get_cache_key_request_headers()
);
$parsed_url = wp_parse_url( $endpoint );

if ( false === $parsed_url ) {
Expand Down Expand Up @@ -91,7 +95,9 @@ protected function get_request_details( HttpQueryInterface $query, array $input_
$pass = ( $user || $pass ) ? $pass . '@' : '';
$origin = sprintf( '%s://%s%s%s%s', $scheme, $user, $pass, $host, $port );

$cache_headers = [];
$cache_headers = [
RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => $cache_key_request_headers,
];
if ( intval( $cache_ttl ) > 0 ) {
$cache_headers[ RdbCacheStrategy::CACHE_TTL_REQUEST_HEADER ] = $cache_ttl;
}
Expand Down
33 changes: 33 additions & 0 deletions inc/HttpClient/CacheKeyRequestHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);

namespace RemoteDataBlocks\HttpClient;

final class CacheKeyRequestHeaders {
private const DEFAULT_HEADERS = [ 'Authorization', 'Cache-Control' ];

/**
* Merge the given header list with DEFAULT_HEADERS, removing case-insensitive duplicates.
*
* @param array<string> $headers Request header names to merge with DEFAULT_HEADERS.
* @return array<string> Merged request header names.
*/
public static function merge( array $headers ): array {
$merged_headers = self::DEFAULT_HEADERS;
$seen_headers = array_fill_keys(
array_map( 'strtolower', self::DEFAULT_HEADERS ),
true
);

foreach ( $headers as $header ) {
$normalized_header = strtolower( $header );
if ( isset( $seen_headers[ $normalized_header ] ) ) {
continue;
}

$seen_headers[ $normalized_header ] = true;
$merged_headers[] = $header;
}

return $merged_headers;
}
}
10 changes: 10 additions & 0 deletions inc/HttpClient/RdbCacheMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

namespace RemoteDataBlocks\HttpClient;

use Psr\Http\Message\RequestInterface;

class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware {
public function __invoke( callable $handler ): callable {
Comment thread
maxschmeling marked this conversation as resolved.
Comment thread
chriszarate marked this conversation as resolved.
$handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) {
return $handler( $request->withoutHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ), $options );
};

return parent::__invoke( $handler_without_cache_metadata );
}

/**
* @var array<string, true>
*/
Expand Down
29 changes: 16 additions & 13 deletions inc/HttpClient/RdbCacheStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,32 @@ class RdbCacheStrategy extends GreedyCacheStrategy {
public const CACHE_AGE_RESPONSE_HEADER = 'Age';
public const CACHE_STATUS_RESPONSE_HEADER = CacheMiddleware::HEADER_CACHE_INFO;
public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL;
public const CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers';
public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks';

private const CACHE_INVALIDATING_REQUEST_HEADERS = [ 'Authorization', 'Cache-Control' ];
private const ERROR_CACHE_TTL_IN_SECONDS = 30; // 30 seconds for error responses
private const FALLBACK_CACHE_TTL_IN_SECONDS = 300; // 5 minutes for success responses

public function __construct( ?CacheStorageInterface $storage = null ) {
// Filter this if customization is needed.
$vary_headers = new KeyValueHttpHeader( self::CACHE_INVALIDATING_REQUEST_HEADERS );

parent::__construct(
$storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ),
self::FALLBACK_CACHE_TTL_IN_SECONDS,
$vary_headers

@chriszarate chriszarate Aug 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cache poisoning is still possible in the future. The vary headers passed to the constructor ([]) and the ones used in get_object_cache_key_from_request are different. That is why I suggested porting the code from GreedyCacheStrategy and no longer extending it. I don't think we can honor its contract.

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.

Yeah, you're right, it's fine now but not safe for the future.

Copied the implementation here 1cde10a

Small tweak here: 9ae5731

Testing and checking now

self::FALLBACK_CACHE_TTL_IN_SECONDS
);
}

public static function get_object_cache_key_from_request( RequestInterface $request ): string {
$request_body = (string) $request->getBody();
$request_headers = $request->getHeaders();
$request_method = $request->getMethod();
$request_uri = (string) $request->getUri();

$cache_key_request_headers = CacheKeyRequestHeaders::merge(
$request->getHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER )
);

$cache_headers = [];
foreach ( self::CACHE_INVALIDATING_REQUEST_HEADERS as $header ) {
if ( isset( $request_headers[ $header ] ) ) {
$cache_headers[ $header ] = $request_headers[ $header ];
foreach ( $cache_key_request_headers as $header ) {
if ( $request->hasHeader( $header ) ) {
$cache_headers[ $header ] = $request->getHeader( $header );
}
}

Expand Down Expand Up @@ -81,11 +80,15 @@ protected function getCacheObject( RequestInterface $request, ResponseInterface
$jitter = intval( ceil( min( $ttl * 0.1, 20 ) ) );
$ttl = intval( $ttl ) + wp_rand( 0, $jitter );

// NOTE: We skip the vary headers '*' check from the parent method
// since our defined vary headers cannot accept a '*' value.
// Cache-key request headers are resolved per request in getCacheKey(), so
// the parent's static vary-header check does not apply here.

$response = $response->withoutHeader( 'Etag' )->withoutHeader( 'Last-Modified' );

return new CacheEntry( $request->withoutHeader( static::HEADER_TTL ), $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) );
$cache_request = $request
->withoutHeader( static::HEADER_TTL )
->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER );

return new CacheEntry( $cache_request, $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) );
}
}
16 changes: 8 additions & 8 deletions inc/HttpClient/RdbLogMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ public function __invoke( callable $handler ): callable {
return function ( RequestInterface $request, array &$options ) use ( $handler ): PromiseInterface {
return $handler( $request, $options )
->then(
$this->handle_success( $request, $options ),
$this->handle_failure( $request, $options )
$this->handle_success( $request ),
Comment thread
maxschmeling marked this conversation as resolved.
Outdated
$this->handle_failure( $request )
);
};
}
Expand Down Expand Up @@ -57,20 +57,20 @@ private function log( RequestInterface $request, ?ResponseInterface $response, ?
/**
* Returns a function which is handled when a request was rejected.
*/
private function handle_failure( RequestInterface $request, array $options ): callable {
return function ( \Exception $reason ) use ( $request, $options ) {
private function handle_failure( RequestInterface $request ): callable {
return function ( \Exception $reason ) use ( $request ) {
$response = ( $reason instanceof RequestException && $reason->hasResponse() === true ) ? $reason->getResponse() : null;
$this->log( $request, $response, $reason, $options );
$this->log( $request, $response, $reason );
return Create::rejectionFor( $reason );
};
}

/**
* Returns a function which is handled when a request was successful.
*/
private function handle_success( RequestInterface $request, array $options ): callable {
return function ( ResponseInterface $response ) use ( $request, $options ) {
$this->log( $request, $response, null, $options );
private function handle_success( RequestInterface $request ): callable {
return function ( ResponseInterface $response ) use ( $request ) {
$this->log( $request, $response, null );
return $response;
};
}
Expand Down
2 changes: 2 additions & 0 deletions inc/Integrations/Shopify/ShopifyIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static function register_blocks(): void {
public static function get_queries( ShopifyDataSource $data_source ): array {
return [
'shopify_get_product' => GraphqlQuery::from_array( [
'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ],
'display_name' => 'Get Shopify product by ID',
'data_source' => $data_source,
'input_schema' => [
Expand Down Expand Up @@ -88,6 +89,7 @@ public static function get_queries( ShopifyDataSource $data_source ): array {
'graphql_query' => file_get_contents( __DIR__ . '/Queries/GetProductById.graphql' ),
] ),
'shopify_search_products' => GraphqlQuery::from_array( [
'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ],
'display_name' => 'Search Shopify products',
'data_source' => $data_source,
'input_schema' => [
Expand Down
1 change: 1 addition & 0 deletions inc/Validation/ConfigSchemas.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ private static function generate_http_query_config_schema(): array {
return Types::object( [
'display_name' => Types::nullable( Types::string() ),
'cache_ttl' => Types::nullable( Types::one_of( Types::callable(), Types::integer(), Types::null() ) ),
'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ),
'data_source' => Types::one_of(
Types::instance_of( HttpDataSource::class ),
Types::serialized_config_for( HttpDataSource::class ),
Expand Down
27 changes: 27 additions & 0 deletions tests/inc/Config/QueryRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
namespace RemoteDataBlocks\Tests\Config;

use GuzzleHttp\Psr7\Response;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use RemoteDataBlocks\Config\Query\HttpQueryInterface;
use RemoteDataBlocks\Config\QueryRunner\QueryRunner;
use RemoteDataBlocks\HttpClient\HttpClient;
use RemoteDataBlocks\HttpClient\RdbCacheStrategy;
use RemoteDataBlocks\Tests\Mocks\MockDataSource;
use RemoteDataBlocks\Tests\Mocks\MockQuery;
use WP_Error;
Expand Down Expand Up @@ -93,6 +96,30 @@ public function testExecuteSuccessfulRequest( string $endpoint ): void {
$this->assertArrayHasKey( 'results', $result );
}

public function testRequestDetailsIncludeCacheKeyRequestHeadersHeader(): void {
$data_source = MockDataSource::create();
$this->assertInstanceOf( MockDataSource::class, $data_source );

$query = MockQuery::create( [
'cache_key_request_headers' => [ 'X-Api-Key' ],
'data_source' => $data_source,
] );
$this->assertInstanceOf( MockQuery::class, $query );

$query_runner = new class($this->http_client, []) extends QueryRunner {
public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error {
return $this->get_request_details( $query, [] );
}
};

$request_details = $query_runner->get_request_details_for_test( $query );
$this->assertIsArray( $request_details );
$this->assertSame(
[ 'Authorization', 'Cache-Control', 'X-Api-Key' ],
$request_details['options'][ RequestOptions::HEADERS ][ RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ] ?? null
);
}

public static function provideInvalidEndpoints(): array {
return [
[
Expand Down
Loading
Loading