Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ The `nginx:alpine` compose backend covers the happy-path smoke tests. A separate
| `GET /set-cookie` | Must return `Set-Cookie`; must not be shared across clients |
| `GET /encoding-article` | Honours `Accept-Encoding` with gzip, zlib-wrapped deflate, or plain text, and sends `Vary: Accept-Encoding` |
| `GET /location-target`, `GET /content-location-target` | Cacheable targets referenced by unsafe-method responses (RFC 9111 §4.4) |
| POST `/dir/mutate?header=Location&ref=item` (also `Content-Location`, dot segments, query and fragment variants) | Must invalidate the resolved cacheable `/dir/item` or `/item` target; ordinary path resolution must preserve unrelated objects |
| unsafe `/create-rel`, `/create-abs`, `/create-abs-default-port`, `/create-abs-empty-port`, `/create-https-default-port`, `/create-abs-mixed-host`, `/create-abs-upper-host`, `/create-cross-host`, `/create-cross-host-mixed`, `/create-content-location`, `/create-content-location-abs-mixed`, `/create-content-location-abs-upper-host` | Must return `Location`/`Content-Location` referencing the targets above, including default-port variants, mixed-case and uppercase same-host, and cross-host forms |

**Every response must include:**
Expand All @@ -170,7 +171,7 @@ The `nginx:alpine` compose backend covers the happy-path smoke tests. A separate
2. Non-static request with `Cookie` is passed per-client — assert separate `X-Backend-Request-Id` values; bob must not see alice's response.
3. Dynamic URL whose query value ends in a static extension (e.g. `?q=jquery.js`) is not treated as a static asset — cookies reach origin per-client with separate request ids, while a real static asset with a query string (`/static/app.css?v=2`) still strips cookies and caches.
4. Response with `Set-Cookie` is never shared from cache — assert separate request ids and `Set-Cookie` values per client.
5. A successful unsafe request whose response carries `Location` or `Content-Location` (RFC 9111 §4.4) invalidates the cached object for the referenced URI on the same host (relative or absolute reference), while a cross-host reference must not invalidate same-host cache entries.
5. A successful unsafe request whose response carries `Location` or `Content-Location` (RFC 9111 §4.4) invalidates the cached object for the referenced URI on the same host (relative or absolute reference), while a cross-host reference must not invalidate same-host cache entries. Relative paths resolve against the unsafe request's directory, remove literal dot segments, preserve the query, and discard the fragment. VCL bounds dot-segment reduction to eight passes; unusually deep references still requiring reduction conservatively invalidate the same host's cache instead of leaving stale data. Scheme-relative (`//host/path`) references remain unsupported.

Out of scope: additional routes, extra Cache-Control permutations, replacing the nginx smoke backend.

Expand Down
58 changes: 55 additions & 3 deletions cache-policy.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,27 @@ sub vcl_backend_fetch {
}
}

# One RFC 3986 dot-segment reduction pass. Only the path is passed here:
# dots in queries and percent-encoded dots are not path navigation.
sub reduce_reference_path {
set beresp.http.X-Varnish-Cache-Ref-Path =
regsuball(beresp.http.X-Varnish-Cache-Ref-Path, "/[.]/", "/");
set beresp.http.X-Varnish-Cache-Ref-Path =
regsuball(beresp.http.X-Varnish-Cache-Ref-Path, "/(?![.][.]?/)[^/]*/[.][.](/|$)", "/");
set beresp.http.X-Varnish-Cache-Ref-Path =
regsub(beresp.http.X-Varnish-Cache-Ref-Path, "^/([.][.]?/)+", "/");
set beresp.http.X-Varnish-Cache-Ref-Path =
regsub(beresp.http.X-Varnish-Cache-Ref-Path, "/[.]$", "/");
set beresp.http.X-Varnish-Cache-Ref-Path =
regsub(beresp.http.X-Varnish-Cache-Ref-Path, "^/[.][.]$", "/");
}

# RFC 9111 §4.4: a successful unsafe response must also invalidate any URI
# referenced in its Location or Content-Location header. The reference is
# passed in beresp.http.X-Varnish-Cache-Ref and resolved against the cache
# identity (host + URL) used by the ban above. Multi-valued fields (Varnish
# joins repeats with ", ") and scheme-relative ("//host/path") or
# relative-without-slash references are skipped: they cannot be resolved to
# a single cache identity safely in VCL.
# joins repeats with ", ") and scheme-relative ("//host/path") references
# remain excluded. Relative paths use the unsafe request URL as their base.
sub invalidate_reference {
if (beresp.http.X-Varnish-Cache-Ref ~ "(?i)^https?://[^/?#]+") {
# Absolute reference: the authority is the cache host identity
Expand Down Expand Up @@ -142,6 +156,42 @@ sub invalidate_reference {
set beresp.http.X-Varnish-Cache-Ref-Host = bereq.http.host;
set beresp.http.X-Varnish-Cache-Ref-URL =
regsub(beresp.http.X-Varnish-Cache-Ref, "#.*$", "");
} elsif (beresp.http.X-Varnish-Cache-Ref !~ "^//" &&
beresp.http.X-Varnish-Cache-Ref !~ "^[A-Za-z][A-Za-z0-9+.-]*:") {
set beresp.http.X-Varnish-Cache-Ref-Host = bereq.http.host;
set beresp.http.X-Varnish-Cache-Ref-URL =
regsub(beresp.http.X-Varnish-Cache-Ref, "#.*$", "");
if (beresp.http.X-Varnish-Cache-Ref-URL == "") {
# A fragment-only reference inherits the base path and query.
set beresp.http.X-Varnish-Cache-Ref-URL = bereq.url;
} elsif (beresp.http.X-Varnish-Cache-Ref-URL ~ "^[?]") {
set beresp.http.X-Varnish-Cache-Ref-URL =
regsub(bereq.url, "[?].*$", "") + beresp.http.X-Varnish-Cache-Ref-URL;
} else {
set beresp.http.X-Varnish-Cache-Ref-URL =
regsub(regsub(bereq.url, "[?].*$", ""), "[^/]*$", "") +
beresp.http.X-Varnish-Cache-Ref-URL;
}
set beresp.http.X-Varnish-Cache-Ref-Path =
regsub(beresp.http.X-Varnish-Cache-Ref-URL, "[?].*$", "");
set beresp.http.X-Varnish-Cache-Ref-Query =
regsub(beresp.http.X-Varnish-Cache-Ref-URL, "^[^?]*", "");
# VCL has no loops. Bound exact reduction work; exceptionally deep
# navigation falls back to a safe same-host ban rather than stale data.
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
call reduce_reference_path;
if (beresp.http.X-Varnish-Cache-Ref-Path ~ "/[.][.]?(/|$)") {
ban("obj.http.X-Varnish-Cache-Host == " + bereq.http.host);
unset beresp.http.X-Varnish-Cache-Ref-Host;
}
set beresp.http.X-Varnish-Cache-Ref-URL =
beresp.http.X-Varnish-Cache-Ref-Path + beresp.http.X-Varnish-Cache-Ref-Query;
}

# The ban expression parser treats horizontal whitespace as token
Expand All @@ -161,6 +211,8 @@ sub invalidate_reference {
unset beresp.http.X-Varnish-Cache-Ref;
unset beresp.http.X-Varnish-Cache-Ref-Host;
unset beresp.http.X-Varnish-Cache-Ref-URL;
unset beresp.http.X-Varnish-Cache-Ref-Path;
unset beresp.http.X-Varnish-Cache-Ref-Query;
}

sub vcl_backend_response {
Expand Down
44 changes: 44 additions & 0 deletions test/e2e/assert-hostile-unsafe-location.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ run_referenced_invalidation_case() {
assert_header_contains "${prefix}-mutation" "$ref_header" "$expected_ref" "${method} ${prefix} mutation"
assert_header_absent "${prefix}-mutation" X-Varnish-Cache-Ref-Host "${method} ${prefix} mutation"
assert_header_absent "${prefix}-mutation" X-Varnish-Cache-Ref-URL "${method} ${prefix} mutation"
assert_header_absent "${prefix}-mutation" X-Varnish-Cache-Ref-Path "${method} ${prefix} mutation"
assert_header_absent "${prefix}-mutation" X-Varnish-Cache-Ref-Query "${method} ${prefix} mutation"
if [[ "$expected_ref" == *" "* || "$expected_ref" == *$'\t'* ]]; then
assert_no_vcl_errors "${method} ${ref_header} reference containing unencoded whitespace"
fi
Expand Down Expand Up @@ -190,6 +192,45 @@ run_variant_port_invalidation_case() {
echo "OK: ${variant_host} Location invalidated the bare-host target"
}

run_relative_path_case() {
local header="$1" reference="$2" target="$3" encoded_reference
encoded_reference="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$reference")"
run_referenced_invalidation_case \
"relative-${header}-${relative_case}" POST \
"/dir/mutate?header=${header}&ref=${encoded_reference}&base=/ignored/directory" \
"$target" "$header" "$reference" 201
http_request relative-cleanup "${base_url}${target}" -X PURGE
relative_case=$((relative_case + 1))
}

run_relative_path_cases() {
local header reference unrelated_id relative_case=0
# An unrelated object must survive ordinary same-host path resolution.
http_request relative-unrelated-first "${base_url}/location-target?case=${case_id}-unrelated"
unrelated_id="$(assert_origin_request_id_present relative-unrelated-first 'unrelated target')"
for header in Location Content-Location; do
for reference in item ./item ../dir/item a/../item ./a/../../dir/item; do
run_relative_path_case "$header" "$reference" /dir/item
done
run_relative_path_case "$header" 'a/b/../..' /dir/
run_relative_path_case "$header" 'a//..' /dir/a/
run_relative_path_case "$header" ../../../item /item
run_relative_path_case "$header" 'item?next=/../literal#fragment' '/dir/item?next=/../literal'
run_relative_path_case "$header" 'item#fragment' /dir/item
run_relative_path_case "$header" '/dir/item' /dir/item
run_relative_path_case "$header" 'http://localhost:8091/dir/item' /dir/item
done
http_request relative-unrelated-after "${base_url}/location-target?case=${case_id}-unrelated"
assert_cache_state relative-unrelated-after HIT 'unrelated target after relative mutations'
assert_same_origin_request_id relative-unrelated-after "$unrelated_id" 'unrelated target after relative mutations'

# More nested navigation than VCL's bounded reducer must still invalidate.
reference="$(printf 'a/%.0s' {1..12})$(printf '../%.0s' {1..12})item"
for header in Location Content-Location; do
run_relative_path_case "$header" "$reference" /dir/item
done
}

case_id="$(openssl rand -hex 4)"
tab="$(printf '\t')"

Expand Down Expand Up @@ -219,6 +260,9 @@ run_referenced_invalidation_case \
"/content-location-target?case=${case_id}-mkcol-content-location" \
200

echo "Testing relative-path references against the unsafe request directory..."
run_relative_path_cases

echo "Testing safe methods and error responses do not invalidate the target..."
for method in GET HEAD OPTIONS TRACE; do
method_slug="$(printf '%s' "$method" | tr '[:upper:]' '[:lower:]')"
Expand Down
14 changes: 14 additions & 0 deletions test/hostile-backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import zlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from itertools import count
from urllib.parse import parse_qs, urlsplit


REQUEST_IDS = count(1)
Expand Down Expand Up @@ -129,6 +130,19 @@ def do_GET(self) -> None:

# Cacheable targets referenced by Location/Content-Location headers
# returned from unsafe-method endpoints (RFC 9111 §4.4).
if clean_path == "/dir/mutate" and self.command == "POST":
params = parse_qs(urlsplit(self.path).query)
header = params.get("header", ["Location"])[0]
reference = params.get("ref", ["item"])[0]
self.respond(201, "mutated\n", extra_headers={header: reference})
return

if clean_path in ("/dir/item", "/item", "/dir/", "/dir/a/"):
self.respond(
200, "relative-target\n", extra_headers={"Cache-Control": "max-age=300"}
)
return

if clean_path == "/location-target":
body = "route=location-target\n"
self.respond(
Expand Down
Loading