Skip to content

Support No-Vary-Search header in Cache API #1798

Open

Description

@yoshisatoyanagisawa

Problem Description
Currently, the Cache API (defined in the ServiceWorker specification) provides mechanisms for matching requests, including ignoreSearch in the match() options. While ignoreSearch: true is useful for ignoring all query parameters, it lacks the granularity needed for modern web applications.

The No-Vary-Search HTTP header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/No-Vary-Search) allows servers to specify which query parameters are relevant for caching, distinguishing between parameters that modify the content (e.g., article_id) and those that do not (e.g., utm_source).

The current Cache API may not understand the No-Vary-Search header. This means developers cannot efficiently cache responses that rely on this mechanism. They are forced to either:

  • Use ignoreSearch: true, which is too broad and may serve the wrong content if some query parameters are significant.
  • Not use ignoreSearch, which leads to cache misses for requests that only differ by insignificant query parameters (like analytics tags).

Proposal
We propose extending the Cache API (Cache.put(), Cache.add(), and CacheStorage.match() / Cache.match()) to understand and respect the No-Vary-Search header.

When a response is stored using put() or add(), the Cache API should (potentially) store the No-Vary-Search rules associated with that response. When match() is called, the API should use these stored rules to determine if a cached response matches the request, even if the query parameters do not match exactly.

The key question is how this support should be implemented. We have identified several potential approaches:

Discussion of Potential Approaches
We see a few ways this could be integrated, primarily concerning whether the behavior is implicit (automatic) or explicit (opt-in by the developer).

Option 1: Explicit add(), Implicit match()
In this model, the developer must explicitly state that No-Vary-Search should be considered when storing the response.

Cache.put(request, response, options): We could add a new option, e.g., noVarySearch: true.

cache.put(request, response, { noVarySearch: true });

CacheStorage.match(request, options): The match() operation would automatically respect the No-Vary-Search rules for any cache entries that were stored with this flag. The match() call itself would not need a special flag.

Pros:
Gives developers control over which entries should use this logic.

Cons:
The match() behavior becomes "magical" or implicit, depending on how the entry was added, which might be confusing.

Option 2: Fully Implicit (Automatic)
This model mirrors how HTTP caches (like browser caches) are expected to work.

Cache.put(request, response): The API automatically inspects the response.headers for a No-Vary-Search header. If present, it stores the response and its No-Vary-Search rules.

CacheStorage.match(request): The match() operation automatically uses the stored No-Vary-Search rules if they exist for a cache entry.

Pros:
Most "standards-compliant" and requires no developer effort if the server sends the correct headers.

Cons:
This changes the fundamental behavior of the Cache API, which has traditionally been a simple key/value (Request/Response) store. Existing caches might not be compatible. Does this apply to all existing cache entries retroactively (if possible) or only new ones?

Option 3: Fully Explicit (Opt-in on both ends)
This model requires the developer to opt-in at both storage time and lookup time, perhaps treating No-Vary-Search-aware entries as a distinct "type" of cache.

Cache.put(request, response, options): Requires an option, e.g., noVarySearch: true.

CacheStorage.match(request, options): Also requires an option, e.g., noVarySearch: true.

A match() call without the option would not match an entry stored with the option, and vice versa. This effectively partitions the cache.

// Store the entry
await cache.put(request, response, { noVarySearch: true });

// This will NOT find it (default behavior)
const match1 = await cache.match(requestWithAnalyticsParams); 

// This WILL find it
const match2 = await cache.match(requestWithAnalyticsParams, { noVarySearch: true });

Pros:
Most explicit and least "magical." It avoids breaking backward compatibility.

Cons:
Most verbose for the developer. It creates a "split brain" in the cache, where the developer must always remember which "mode" their entries are stored in.

Activity

  1. changed the title [-]Proposal: Support No-Vary-Search header in Cache API[/-] [+]Support No-Vary-Search header in Cache API[/+] on Oct 21, 2025
  2. yoshisatoyanagisawa commented on Oct 21, 2025

    @yoshisatoyanagisawa
    CollaboratorAuthor

    Considering how Vary is treated in the specification (https://w3c.github.io/ServiceWorker/#request-matches-cached-item), we may not need the flag for adding? We might just store the no-vary-search header as-is, and we consider it on look up upon the given option.

  3. yoshisatoyanagisawa commented on Oct 21, 2025

    @yoshisatoyanagisawa
    CollaboratorAuthor
  4. yoshisatoyanagisawa commented on Oct 27, 2025

    @yoshisatoyanagisawa
    CollaboratorAuthor
  5. ricea commented on Oct 28, 2025

    @ricea
    Contributor

    An issue is that opaque resources can be stored by the Cache API, however we shouldn't reveal anything about opaque resources to JavaScript. If the Cache API supported the No-Vary-Search response header as described, then a malicious site could determine whether or not the header is present, which might reveal information about whether the user is logged in or maybe even what searches they have been doing.

    One mitigation would be to only support "Fully explicit" mode, and require JavaScript to explicitly specify the header value to use.

  6. yoshisatoyanagisawa commented on Oct 29, 2025

    @yoshisatoyanagisawa
    CollaboratorAuthor

    Thanks for pointing out the security risk with opaque resources.

    I agree that a fully explicit approach is the right way to avoid information leaks. To build on that idea, as you suggested, we should require the developer to provide the actual No-Vary-Search rules to both put() and match(), rather than just a boolean noVarySearch: true flag.

    For example:

    const rules = 'params, except=("id")';
    await cache.put(request, response, { noVarySearchRules: rules });
    const match = await cache.match(requestWithOtherParams, { noVarySearchRules: rules });
    

    Note that the logic ignores No-Vary-Search header in the response. This approach directly addresses the security concern you raised. The matching logic would rely entirely on the rules provided by the developer at runtime.

    This also seems like a natural fit for the Cache API's design. The Service Worker specification defines the Cache API as a straightforward key-value store, unlike the browser's HTTP cache, which can employ implicit, path-based mechanisms to remember the last seen header. Since the Cache API doesn't have a mechanism for storing metadata per directory, requiring the rule at match() time is necessary to resolve ambiguity and aligns with the explicit nature of the API.

  7. wanderview commented on Oct 29, 2025

    @wanderview
    Member

    An issue is that opaque resources can be stored by the Cache API, however we shouldn't reveal anything about opaque resources to JavaScript. If the Cache API supported the No-Vary-Search response header as described, then a malicious site could determine whether or not the header is present, which might reveal information about whether the user is logged in or maybe even what searches they have been doing.

    Do we expect that No-Vary-Search will be served differently based on user state? It seems likely this is going to be a static header applied based on the type of resource; like cache control headers. Indeed, this is another cache control header.

    Correct me if I'm wrong, but I think currently http cache honors cache control headers for non-cors subresource requests. Does http cache honor this header for no-cors subresource requests? (I'm guessing yes.). It would seem reasonable to do the same for opaque responses here as well.

    I agree normally we don't want to reveal any details about opaque responses, but I think the threat model there is actually about variable contents of the response (content, cookies, etc).

  8. ricea commented on Oct 29, 2025

    @ricea
    Contributor

    Do we expect that No-Vary-Search will be served differently based on user state?

    In principle, no. The intention is that URLs that differ only in query should all be served with the same No-Vary-Search value. In practice we've already seen this expectation violated in production.

    The issue is that sites serving a 404 or 403 or redirect because the user is not logged on will frequently not have the same response headers as the logged-in case. Historically this was used as an attack on the HTTP cache, but I think all major browsers have mitigated that with cache partitioning now.

  9. wanderview commented on Oct 29, 2025

    @wanderview
    Member

    The cache API is also partitioned. Does this not mitigate it like in the http cache case?

    How does No-Vary-Search work in the http cache for no-cors subresources?

  10. ricea commented on Oct 30, 2025

    @ricea
    Contributor

    The cache API is also partitioned. Does this not mitigate it like in the http cache case?

    On second thought, maybe cache partitioning doesn't mitigate this. An attacker can perform a timing attack with "no-cors" fetch to determine whether or not an item is cached. The thing that's saving us in practice is that most of these login-guarded resources cannot be served fresh from cache, and cannot be revalidated. so the attacker has to determine the difference between "there was a cache entry but it couldn't be used" and "there was no cache entry" but those two times only differ by microseconds at most.

    On the other hand, the Cache API doesn't care about staleness, so asking the question "is there a cache entry for this" is easy and 100% reliable.

    To sketch an attack, the malicious page does:

    await cache.add("https://target/protected/page?ignoredname=1");
    if (await cache.match("https://target/protected/page?ignoredname=2")) {
      userIsLoggedInToTarget = true;
    }
    
  11. asutherland commented on Nov 7, 2025

    @asutherland

    Can you describe the attack's configuration in more detail as it relates to the browsing contexts and where the malicious page and its ServiceWorker sit in this?

    Specifically, it seems like if the malicious page is the top-level browsing context and the browser is partitioning, then any fetches made by the (first-party) ServiceWorker will themselves be partitioned with the malicious site as the partition key, so the only way any entropy could be extracted is if the malicious page convinces the user to log into the site in an iframe (thereby having the partition key).

  12. ricea commented on Nov 7, 2025

    @ricea
    Contributor

    Specifically, it seems like if the malicious page is the top-level browsing context and the browser is partitioning, then any fetches made by the (first-party) ServiceWorker will themselves be partitioned with the malicious site as the partition key, so the only way any entropy could be extracted is if the malicious page convinces the user to log into the site in an iframe (thereby having the partition key).

    In Chrome cookies are not partitioned. In the default configuration, third-party cookies are enabled, so if the user has logged into target in any context the attack will work, with no user action required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions