From ccf7a26e794b3b08a302ac0b80ca4298a4ebb334 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Sun, 15 Mar 2026 22:15:04 -0300 Subject: [PATCH 01/20] Add cache poisoning level 1 vulnerability --- .../CachePoisoningVulnerability.java | 82 +++++++++++++ .../cachePoisoning/CachedResponse.java | 39 +++++++ .../types/VulnerabilityType.java | 4 +- .../CachePoisoningPayload.properties | 5 + src/main/resources/i18n/messages.properties | 12 +- .../resources/i18n/messages_en_US.properties | 12 +- .../CachePoisoning/LEVEL_1/CachePoisoning.css | 88 ++++++++++++++ .../LEVEL_1/CachePoisoning.html | 36 ++++++ .../CachePoisoning/LEVEL_1/CachePoisoning.js | 68 +++++++++++ .../CachePoisoningVulnerabilityTest.java | 109 ++++++++++++++++++ 10 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java create mode 100644 src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java create mode 100644 src/main/resources/attackvectors/CachePoisoningPayload.properties create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js create mode 100644 src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java new file mode 100644 index 000000000..936976da6 --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -0,0 +1,82 @@ +package org.sasanlabs.service.vulnerability.cachePoisoning; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import javax.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.sasanlabs.internal.utility.LevelConstants; +import org.sasanlabs.internal.utility.annotations.AttackVector; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.sasanlabs.vulnerability.types.VulnerabilityType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; + +@VulnerableAppRestController( + descriptionLabel = "CACHE_POISONING_VULNERABILITY", + value = "CachePoisoning") +public class CachePoisoningVulnerability { + + static final String BANNER_QUERY_PARAMETER = "banner"; + static final String CACHE_STATUS_HEADER = "X-Cache-Status"; + static final String CACHE_KEY_HEADER = "X-Cache-Key"; + static final String CACHE_STATUS_HIT = "HIT"; + static final String CACHE_STATUS_MISS = "MISS"; + static final String CACHE_CONTROL_PUBLIC = "public, max-age=60"; + static final String DEFAULT_BANNER = "Welcome to the shared cache demo"; + static final Duration DEFAULT_TTL = Duration.ofSeconds(60); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_1") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_1, + htmlTemplate = "LEVEL_1/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel1( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + HttpServletRequest request) { + String responseContent = buildLevel1Response(banner); + Instant now = Instant.now(); + String cacheKey = request.getRequestURI(); + CachedResponse cachedResponse = cache.get(cacheKey); + boolean cacheHit = cachedResponse != null && !cachedResponse.isExpired(now); + if (!cacheHit) { + cachedResponse = new CachedResponse(cacheKey, responseContent, now, DEFAULT_TTL); + cache.put(cacheKey, cachedResponse); + } + + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setCacheControl(CACHE_CONTROL_PUBLIC); + responseHeaders.add(CACHE_STATUS_HEADER, cacheHit ? CACHE_STATUS_HIT : CACHE_STATUS_MISS); + responseHeaders.add(CACHE_KEY_HEADER, cachedResponse.getCacheKey()); + + return ResponseEntity.ok() + .headers(responseHeaders) + .body(new GenericVulnerabilityResponseBean<>(cachedResponse.getContent(), true)); + } + + private String buildLevel1Response(String banner) { + String safeBanner = + StringEscapeUtils.escapeHtml4( + StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); + return "
" + + "

Shared cache response

" + + "

Banner: " + + safeBanner + + "

" + + "

This level intentionally caches the page by route only.

" + + "

Poison the cache once with a crafted banner and the next visitor reuses it.

" + + "
"; + } + + void clearCache() { + cache.clear(); + } +} diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java new file mode 100644 index 000000000..2340adbeb --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java @@ -0,0 +1,39 @@ +package org.sasanlabs.service.vulnerability.cachePoisoning; + +import java.time.Duration; +import java.time.Instant; + +public class CachedResponse { + + private final String cacheKey; + private final String content; + private final Instant createdAt; + private final Duration ttl; + + public CachedResponse(String cacheKey, String content, Instant createdAt, Duration ttl) { + this.cacheKey = cacheKey; + this.content = content; + this.createdAt = createdAt; + this.ttl = ttl; + } + + public boolean isExpired(Instant now) { + return !createdAt.plus(ttl).isAfter(now); + } + + public String getCacheKey() { + return cacheKey; + } + + public String getContent() { + return content; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Duration getTtl() { + return ttl; + } +} diff --git a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java index d4e6a2367..b04921936 100644 --- a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java +++ b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java @@ -50,7 +50,9 @@ public enum VulnerabilityType { INSECURE_DIRECT_OBJECT_REFERENCE(639, 13), // Clickjacking - CLICKJACKING(1021, null); + CLICKJACKING(1021, null), + + WEB_CACHE_POISONING(null, null); private Integer cweID; private Integer wascID; diff --git a/src/main/resources/attackvectors/CachePoisoningPayload.properties b/src/main/resources/attackvectors/CachePoisoningPayload.properties new file mode 100644 index 000000000..a92c5e5b8 --- /dev/null +++ b/src/main/resources/attackvectors/CachePoisoningPayload.properties @@ -0,0 +1,5 @@ +CACHE_POISONING_PAYLOAD_LEVEL_1=This level simulates a shared cache that stores the response only by route.
\ +How to exploit this level?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=FAKE%20URGENT%20UPDATE to poison the cache.
\ +2. Call /VulnerableApp/CachePoisoning/LEVEL_1 without the query parameter.
\ +3. The second response is served from cache and still contains the poisoned banner. diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 7d298175e..e91f1888b 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -351,4 +351,14 @@ CLICKJACKING_X_FRAME_OPTIONS_SAMEORIGIN=X-Frame-Options header is set to SAMEORI CLICKJACKING_OVERLAY_NO_PROTECTION=No framing protection header is set. This level demonstrates a UI overlay attack: the attacker overlays a nearly-transparent victim page on top of fake "prize" content. The victim clicks what appears to be a prize button but unknowingly triggers a sensitive action on the victim page. CLICKJACKING_OVERLAY_SAMEORIGIN=X-Frame-Options header is set to SAMEORIGIN, but this only prevents cross-origin framing. A same-origin attacker page can still overlay the victim page and perform a UI redress attack. This level shows that SAMEORIGIN alone is insufficient when the attacker controls a page on the same origin. CLICKJACKING_X_FRAME_OPTIONS_DENY=X-Frame-Options header is set to DENY, preventing the page from being embedded in any iframe regardless of origin. This is a secure configuration. -CLICKJACKING_CSP_FRAME_ANCESTORS_NONE=Content-Security-Policy frame-ancestors 'none' header is set, which is the modern and recommended way to prevent clickjacking. It supersedes X-Frame-Options and offers finer-grained control. \ No newline at end of file +CLICKJACKING_CSP_FRAME_ANCESTORS_NONE=Content-Security-Policy frame-ancestors 'none' header is set, which is the modern and recommended way to prevent clickjacking. It supersedes X-Frame-Options and offers finer-grained control. + +# Cache Poisoning Vulnerability +CACHE_POISONING_VULNERABILITY=Web Cache Poisoning happens when an attacker makes a cache store an incorrect response and that response is then reused for other users. \ +This level uses a shared in-memory cache to show the core issue: the response changes with user input, but the cache key only uses the route. \ +Important Links:
\ +
  1. OWASP Cache Poisoning \ +
  2. PortSwigger Web Cache Poisoning \ +
  3. RFC 9111 - HTTP Caching \ +
+CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. diff --git a/src/main/resources/i18n/messages_en_US.properties b/src/main/resources/i18n/messages_en_US.properties index c0ebb66bc..6644bf748 100755 --- a/src/main/resources/i18n/messages_en_US.properties +++ b/src/main/resources/i18n/messages_en_US.properties @@ -288,4 +288,14 @@ SSRF_VULNERABILITY_URL_IF_NOT_FILE_PROTOCOL_AND_INTERNAL_METADATA_URL=file:// pr SSRF_VULNERABILITY_URL_ONLY_IF_IN_THE_WHITELIST=Only Whitelisted URL is allowed. # JWT Injection Header -HEADER_INJECTION_VULNERABILITY=It tests how a JWT header can be manipulated to alter the signature verification. \ No newline at end of file +HEADER_INJECTION_VULNERABILITY=It tests how a JWT header can be manipulated to alter the signature verification. + +# Cache Poisoning Vulnerability +CACHE_POISONING_VULNERABILITY=Web Cache Poisoning happens when an attacker makes a cache store an incorrect response and that response is then reused for other users. \ +This level uses a shared in-memory cache to show the core issue: the response changes with user input, but the cache key only uses the route. \ +Important Links:
\ +
  1. OWASP Cache Poisoning \ +
  2. PortSwigger Web Cache Poisoning \ +
  3. RFC 9111 - HTTP Caching \ +
+CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css new file mode 100644 index 000000000..d0dc1bb71 --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css @@ -0,0 +1,88 @@ +#cachePoisoningLevel1 { + padding: 12px 0; +} + +.cache-poisoning-panel { + border: 1px solid #d9d9d9; + border-radius: 10px; + padding: 20px; + background: #fafafa; +} + +.cache-poisoning-title { + margin-top: 0; + margin-bottom: 10px; +} + +.cache-poisoning-copy { + margin-bottom: 16px; + line-height: 1.5; +} + +.cache-poisoning-label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +.cache-poisoning-input { + width: 100%; + max-width: 420px; + padding: 10px 12px; + border: 1px solid #b9b9b9; + border-radius: 6px; + margin-bottom: 16px; +} + +.cache-poisoning-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + +.cache-poisoning-actions button { + padding: 10px 14px; + border: 0; + border-radius: 6px; + background: #13315c; + color: #fff; + cursor: pointer; +} + +.cache-poisoning-actions button:hover { + background: #0f2748; +} + +.cache-poisoning-diagnostics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.diagnostic-card { + padding: 12px; + border-radius: 6px; + background: #fff; + border: 1px solid #e1e1e1; +} + +.diagnostic-label { + display: block; + margin-bottom: 8px; + font-size: 0.9rem; + color: #3d3d3d; +} + +.cache-poisoning-output { + min-height: 120px; + padding: 14px; + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 6px; +} + +.cache-poisoning-response p { + margin: 8px 0; +} diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html new file mode 100644 index 000000000..2eefa4c72 --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html @@ -0,0 +1,36 @@ +
+
+

Shared cache keyed only by route

+

+ Send one request as the attacker with a custom banner and then a second request as the + victim without the query parameter. +

+ + + + +
+ + +
+ +
+
+ Cache status + - +
+
+ Cache key + - +
+
+ +
+ Use the buttons above to poison the cache and replay the cached response. +
+
+
diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js new file mode 100644 index 000000000..d750b1e13 --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js @@ -0,0 +1,68 @@ +function getBannerValue() { + return document.getElementById("bannerInput").value.trim(); +} + +function getLevel1Url(includeBanner) { + let url = getUrlForVulnerabilityLevel(); + if (!includeBanner) { + return url; + } + + let banner = getBannerValue(); + if (!banner) { + return url; + } + + return url + "?banner=" + encodeURIComponent(banner); +} + +function updateDiagnostics(xmlHttpRequest) { + document.getElementById("cacheStatus").textContent = + xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + document.getElementById("cacheKey").textContent = + xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; +} + +function updateResponseArea(content) { + document.getElementById("cachePoisoningResponse").innerHTML = content; +} + +function sendCachePoisoningRequest(method, url) { + let xmlHttpRequest = new XMLHttpRequest(); + xmlHttpRequest.onreadystatechange = function () { + if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { + return; + } + + if (xmlHttpRequest.status !== 200) { + alert("Request failed"); + return; + } + + let data = JSON.parse(xmlHttpRequest.responseText); + updateDiagnostics(xmlHttpRequest); + updateResponseArea(data.content); + }; + + xmlHttpRequest.open(method, url, true); + if (method === "GET") { + xmlHttpRequest.setRequestHeader("Content-Type", "application/json"); + } + xmlHttpRequest.send(); +} + +function addEvents() { + document + .getElementById("poisonCacheBtn") + .addEventListener("click", function () { + sendCachePoisoningRequest("GET", getLevel1Url(true)); + }); + + document + .getElementById("victimRequestBtn") + .addEventListener("click", function () { + sendCachePoisoningRequest("GET", getLevel1Url(false)); + }); +} + +addEvents(); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java new file mode 100644 index 000000000..aab96adef --- /dev/null +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -0,0 +1,109 @@ +package org.sasanlabs.service.vulnerability.cachePoisoning; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +class CachePoisoningVulnerabilityTest { + + private static final String LEVEL_1_PATH = "/VulnerableApp/CachePoisoning/LEVEL_1"; + private static final String ATTACKER_BANNER = "ATUALIZACAO URGENTE FAKE"; + private static final String SAFE_BANNER = "Welcome to the shared cache demo"; + + private CachePoisoningVulnerability cachePoisoningVulnerability; + + @BeforeEach + void setUp() { + cachePoisoningVulnerability = new CachePoisoningVulnerability(); + cachePoisoningVulnerability.clearCache(); + } + + @Test + @DisplayName("Level 1 - Attacker poisons the route cache and victim gets a cached hit") + void level1ShouldPoisonSharedCacheAcrossRequests() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + ATTACKER_BANNER, createLevel1Request("poison")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(ATTACKER_BANNER); + } + + @Test + @DisplayName("Level 1 - Cache key ignores the banner query parameter") + void level1ShouldUseOnlyRouteAsCacheKey() { + ResponseEntity> firstResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + "FIRST BANNER", createLevel1Request("FIRST")); + ResponseEntity> secondResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + "SECOND BANNER", createLevel1Request("SECOND")); + + assertThat(firstResponse.getHeaders().getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_1_PATH); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_1_PATH); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(secondResponse.getBody().getContent()).contains("FIRST BANNER"); + assertThat(secondResponse.getBody().getContent()).doesNotContain("SECOND BANNER"); + } + + @Test + @DisplayName("Level 1 - Cache-Control is public with max-age 60 seconds") + void level1ShouldExposePublicCacheHeaders() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, createLevel1Request(null)); + + assertValidResponse(response); + assertThat(response.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(response.getBody().getContent()).contains(SAFE_BANNER); + } + + private void assertValidResponse( + ResponseEntity> responseEntity) { + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(responseEntity.getBody()).isNotNull(); + assertThat(responseEntity.getBody().getIsValid()).isTrue(); + } + + private MockHttpServletRequest createLevel1Request(String queryString) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + request.setRequestURI(LEVEL_1_PATH); + if (queryString != null) { + request.setQueryString("banner=" + queryString); + } + return request; + } +} From 0f9071a8839f5079e05a0684dae123ee48d1f200 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Mon, 16 Mar 2026 17:57:40 -0300 Subject: [PATCH 02/20] Refine cache poisoning level 1 UI --- .../CachePoisoningVulnerability.java | 3 +- .../CachePoisoning/LEVEL_1/CachePoisoning.css | 190 +++++++++++++----- .../LEVEL_1/CachePoisoning.html | 73 ++++--- .../CachePoisoningVulnerabilityTest.java | 5 +- 4 files changed, 187 insertions(+), 84 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 936976da6..9fb5358a8 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -64,8 +64,7 @@ public ResponseEntity> getVulnerablePay private String buildLevel1Response(String banner) { String safeBanner = - StringEscapeUtils.escapeHtml4( - StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); + StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); return "
" + "

Shared cache response

" + "

Banner: " diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css index d0dc1bb71..79fcbe5ed 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css @@ -1,88 +1,176 @@ -#cachePoisoningLevel1 { - padding: 12px 0; +.cache-poisoning-shell { + width: 100%; + max-width: 720px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + color: black; + text-align: center; } -.cache-poisoning-panel { - border: 1px solid #d9d9d9; - border-radius: 10px; - padding: 20px; - background: #fafafa; +.cache-poisoning-title { + margin: 0 0 6px; + font-size: 22px; } -.cache-poisoning-title { - margin-top: 0; - margin-bottom: 10px; +.cache-poisoning-subtitle { + max-width: 560px; + margin: 0 auto 10px; + font-size: 13px; + line-height: 1.45; +} + +.cache-poisoning-layout { + display: grid; + gap: 12px; +} + +.cache-poisoning-block { + border: 1px solid black; + border-left: 6px solid burlywood; + border-radius: 4px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.4); + text-align: left; +} + +.cache-poisoning-block h5 { + margin: 0 0 10px; + font-size: 16px; + font-weight: bold; + text-align: center; +} + +.cache-poisoning-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: center; + gap: 10px; } -.cache-poisoning-copy { - margin-bottom: 16px; - line-height: 1.5; +.cache-poisoning-field { + flex: 1 1 320px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; } .cache-poisoning-label { - display: block; - font-weight: 600; - margin-bottom: 6px; + font-size: 12px; + font-weight: bold; } .cache-poisoning-input { - width: 100%; - max-width: 420px; - padding: 10px 12px; - border: 1px solid #b9b9b9; - border-radius: 6px; - margin-bottom: 16px; + width: 100%; + box-sizing: border-box; + height: 34px; + padding: 4px 8px; + border: 1px solid black; + border-radius: 3px; + background: rgba(255, 255, 255, 0.9); + color: black; + font-size: 12px; } .cache-poisoning-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-bottom: 18px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; } .cache-poisoning-actions button { - padding: 10px 14px; - border: 0; - border-radius: 6px; - background: #13315c; - color: #fff; - cursor: pointer; + background: blueviolet; + display: inline-block; + padding: 8px 10px; + margin: 2px; + border: 2px solid transparent; + border-radius: 3px; + transition: 0.2s opacity; + color: #fff; + font-size: 12px; } .cache-poisoning-actions button:hover { - background: #0f2748; + opacity: 0.9; + cursor: pointer; } .cache-poisoning-diagnostics { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 18px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; } .diagnostic-card { - padding: 12px; - border-radius: 6px; - background: #fff; - border: 1px solid #e1e1e1; + padding: 12px; + border: 1px solid black; + border-radius: 4px; + background: rgba(255, 255, 255, 0.55); + text-align: center; } .diagnostic-label { - display: block; - margin-bottom: 8px; - font-size: 0.9rem; - color: #3d3d3d; + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: bold; +} + +#cacheStatus, +#cacheKey { + display: block; + padding: 6px 8px; + border: 1px solid black; + border-radius: 3px; + background: rgba(255, 255, 255, 0.85); + word-break: break-word; + font-size: 12px; } .cache-poisoning-output { - min-height: 120px; - padding: 14px; - background: #fff; - border: 1px solid #e1e1e1; - border-radius: 6px; + min-height: 110px; + padding: 12px; + border: 1px solid black; + border-radius: 4px; + background: rgba(255, 255, 255, 0.55); + text-align: left; + font-size: 13px; + line-height: 1.5; +} + +.cache-poisoning-response h3 { + margin: 0 0 10px; + font-size: 16px; } .cache-poisoning-response p { - margin: 8px 0; + margin: 8px 0; +} + +@media (max-width: 640px) { + .cache-poisoning-shell { + margin: 8px auto; + padding: 2px; + } + + .cache-poisoning-title { + font-size: 20px; + } + + .cache-poisoning-form { + flex-direction: column; + align-items: stretch; + } + + .cache-poisoning-field { + align-items: stretch; + } + + .cache-poisoning-input, + .cache-poisoning-actions button { + width: 100%; + } } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html index 2eefa4c72..a130759bf 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html @@ -1,36 +1,49 @@ -

-
-

Shared cache keyed only by route

-

- Send one request as the attacker with a custom banner and then a second request as the - victim without the query parameter. -

+
+

Cache Poisoning

+

+ A shared cache stores responses by route only. Use the controls below and rely on the app + help panel for exploitation guidance. +

- - - -
- - -
- -
-
- Cache status - - +
+
+
Controls
+
+
+ + +
+
+ + +
-
- Cache key - - +
+ +
+
Cache Signals
+
+
+ Cache status + - +
+
+ Cache key + - +
-
+
-
- Use the buttons above to poison the cache and replay the cached response. -
+
+
Observed Response
+
+ Use the controls above to poison the cache and verify whether the victim receives + the attacker-controlled banner. +
+
diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java index aab96adef..08f92c3fe 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -61,7 +61,10 @@ void level1ShouldUseOnlyRouteAsCacheKey() { cachePoisoningVulnerability.getVulnerablePayloadLevel1( "SECOND BANNER", createLevel1Request("SECOND")); - assertThat(firstResponse.getHeaders().getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + assertThat( + firstResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) .isEqualTo(LEVEL_1_PATH); assertThat( secondResponse From 7cda09cf53be0669f35f3f9ed9cb42a9ca0fd2dd Mon Sep 17 00:00:00 2001 From: luks-santos Date: Tue, 17 Mar 2026 21:07:46 -0300 Subject: [PATCH 03/20] Add cache poisoning level 2 demo --- .../CachePoisoningVulnerability.java | 72 +++++-- .../CachePoisoningPayload.properties | 5 + src/main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_en_US.properties | 1 + .../CachePoisoning/LEVEL_2/CachePoisoning.css | 176 ++++++++++++++++++ .../LEVEL_2/CachePoisoning.html | 50 +++++ .../CachePoisoning/LEVEL_2/CachePoisoning.js | 68 +++++++ .../CachePoisoningVulnerabilityTest.java | 70 ++++++- 8 files changed, 429 insertions(+), 14 deletions(-) create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 9fb5358a8..0409e3039 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.time.Instant; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; import javax.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; @@ -29,6 +30,11 @@ public class CachePoisoningVulnerability { static final String CACHE_CONTROL_PUBLIC = "public, max-age=60"; static final String DEFAULT_BANNER = "Welcome to the shared cache demo"; static final Duration DEFAULT_TTL = Duration.ofSeconds(60); + private static final Pattern SCRIPT_BLOCK_PATTERN = + Pattern.compile("(?is)<\\s*script\\b[^>]*>.*?<\\s*/\\s*script\\s*>"); + private static final Pattern SCRIPT_TAG_PATTERN = + Pattern.compile("(?is)<\\s*/?\\s*script\\b[^>]*>"); + private static final Pattern JAVASCRIPT_SCHEME_PATTERN = Pattern.compile("(?i)javascript\\s*:"); private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); @@ -43,6 +49,59 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, HttpServletRequest request) { String responseContent = buildLevel1Response(banner); + return buildCachedResponse(request, responseContent); + } + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_2_NAIVE_FILTER", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_2") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_2, + htmlTemplate = "LEVEL_2/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel2( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + HttpServletRequest request) { + String responseContent = buildLevel2Response(banner); + return buildCachedResponse(request, responseContent); + } + + private String buildLevel1Response(String banner) { + String safeBanner = + StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); + return "
" + + "

Shared cache response

" + + "

Banner: " + + safeBanner + + "

" + + "

This level intentionally caches the page by route only.

" + + "

Poison the cache once with a crafted banner and the next visitor reuses it.

" + + "
"; + } + + private String buildLevel2Response(String banner) { + String filteredBanner = applyNaiveBannerFilter(banner); + return "
" + + "

Shared cache response

" + + "

Banner: " + + filteredBanner + + "

" + + "

This level strips obvious <script> payloads but still caches by route" + + " only.

" + + "

Misleading text and harmless-looking HTML still poison the shared cache.

" + + "
"; + } + + private String applyNaiveBannerFilter(String banner) { + String candidate = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER).trim(); + candidate = SCRIPT_BLOCK_PATTERN.matcher(candidate).replaceAll(""); + candidate = SCRIPT_TAG_PATTERN.matcher(candidate).replaceAll(""); + candidate = JAVASCRIPT_SCHEME_PATTERN.matcher(candidate).replaceAll(""); + return StringUtils.defaultIfBlank(candidate, DEFAULT_BANNER); + } + + private ResponseEntity> buildCachedResponse( + HttpServletRequest request, String responseContent) { Instant now = Instant.now(); String cacheKey = request.getRequestURI(); CachedResponse cachedResponse = cache.get(cacheKey); @@ -62,19 +121,6 @@ public ResponseEntity> getVulnerablePay .body(new GenericVulnerabilityResponseBean<>(cachedResponse.getContent(), true)); } - private String buildLevel1Response(String banner) { - String safeBanner = - StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); - return "
" - + "

Shared cache response

" - + "

Banner: " - + safeBanner - + "

" - + "

This level intentionally caches the page by route only.

" - + "

Poison the cache once with a crafted banner and the next visitor reuses it.

" - + "
"; - } - void clearCache() { cache.clear(); } diff --git a/src/main/resources/attackvectors/CachePoisoningPayload.properties b/src/main/resources/attackvectors/CachePoisoningPayload.properties index a92c5e5b8..17ac566c4 100644 --- a/src/main/resources/attackvectors/CachePoisoningPayload.properties +++ b/src/main/resources/attackvectors/CachePoisoningPayload.properties @@ -3,3 +3,8 @@ CACHE_POISONING_PAYLOAD_LEVEL_1=This level simulates a shared cache that stores 1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=FAKE%20URGENT%20UPDATE to poison the cache.
\ 2. Call /VulnerableApp/CachePoisoning/LEVEL_1 without the query parameter.
\ 3. The second response is served from cache and still contains the poisoned banner. +CACHE_POISONING_PAYLOAD_LEVEL_2=This level adds a naive filter that removes only obvious script payloads, but the cache key is still based on the route alone.
\ +How to exploit this level?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cb%3EURGENT%20STATUS%20UPDATE%3C/b%3E to poison the cache with harmless-looking HTML.
\ +2. Call /VulnerableApp/CachePoisoning/LEVEL_2 without the query parameter.
\ +3. The victim still receives the cached attacker-controlled response even though the app tried to filter the input. diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index e91f1888b..aadd4188f 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -362,3 +362,4 @@ Important Links:
\
  • RFC 9111 - HTTP Caching \ CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. +CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. diff --git a/src/main/resources/i18n/messages_en_US.properties b/src/main/resources/i18n/messages_en_US.properties index 6644bf748..8847c8b30 100755 --- a/src/main/resources/i18n/messages_en_US.properties +++ b/src/main/resources/i18n/messages_en_US.properties @@ -299,3 +299,4 @@ Important Links:
    \
  • RFC 9111 - HTTP Caching \ CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. +CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css new file mode 100644 index 000000000..357b09fda --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css @@ -0,0 +1,176 @@ +.cache-poisoning-shell { + width: 100%; + max-width: 720px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + color: black; + text-align: center; +} + +.cache-poisoning-title { + margin: 0 0 6px; + font-size: 22px; +} + +.cache-poisoning-subtitle { + max-width: 560px; + margin: 0 auto 10px; + font-size: 13px; + line-height: 1.45; +} + +.cache-poisoning-layout { + display: grid; + gap: 12px; +} + +.cache-poisoning-block { + border: 1px solid black; + border-left: 6px solid chocolate; + border-radius: 4px; + padding: 10px 12px; + background: rgba(255, 248, 240, 0.7); + text-align: left; +} + +.cache-poisoning-block h5 { + margin: 0 0 10px; + font-size: 16px; + font-weight: bold; + text-align: center; +} + +.cache-poisoning-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: center; + gap: 10px; +} + +.cache-poisoning-field { + flex: 1 1 320px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.cache-poisoning-label { + font-size: 12px; + font-weight: bold; +} + +.cache-poisoning-input { + width: 100%; + box-sizing: border-box; + height: 34px; + padding: 4px 8px; + border: 1px solid black; + border-radius: 3px; + background: rgba(255, 255, 255, 0.95); + color: black; + font-size: 12px; +} + +.cache-poisoning-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.cache-poisoning-actions button { + background: sienna; + display: inline-block; + padding: 8px 10px; + margin: 2px; + border: 2px solid transparent; + border-radius: 3px; + transition: 0.2s opacity; + color: #fff; + font-size: 12px; +} + +.cache-poisoning-actions button:hover { + opacity: 0.9; + cursor: pointer; +} + +.cache-poisoning-diagnostics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.diagnostic-card { + padding: 12px; + border: 1px solid black; + border-radius: 4px; + background: rgba(255, 255, 255, 0.7); + text-align: center; +} + +.diagnostic-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: bold; +} + +#cacheStatus, +#cacheKey { + display: block; + padding: 6px 8px; + border: 1px solid black; + border-radius: 3px; + background: rgba(255, 255, 255, 0.9); + word-break: break-word; + font-size: 12px; +} + +.cache-poisoning-output { + min-height: 110px; + padding: 12px; + border: 1px solid black; + border-radius: 4px; + background: rgba(255, 255, 255, 0.75); + text-align: left; + font-size: 13px; + line-height: 1.5; +} + +.cache-poisoning-response h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.cache-poisoning-response p { + margin: 8px 0; +} + +@media (max-width: 640px) { + .cache-poisoning-shell { + margin: 8px auto; + padding: 2px; + } + + .cache-poisoning-title { + font-size: 20px; + } + + .cache-poisoning-form { + flex-direction: column; + align-items: stretch; + } + + .cache-poisoning-field { + align-items: stretch; + } + + .cache-poisoning-input, + .cache-poisoning-actions button { + width: 100%; + } +} diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html new file mode 100644 index 000000000..593db8bc2 --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html @@ -0,0 +1,50 @@ +
    +

    Cache Poisoning

    +

    + This level blocks only obvious script payloads. Try a harmless-looking HTML banner or a + misleading status message and observe that the shared cache still replays it to the next + visitor. +

    + +
    +
    +
    Controls
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    Cache Signals
    +
    +
    + Cache status + - +
    +
    + Cache key + - +
    +
    +
    + +
    +
    Observed Response
    +
    + Poison the cache with a filtered banner, then send the victim request and confirm + the same response is replayed from the shared cache. +
    +
    +
    +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js new file mode 100644 index 000000000..1c10f2070 --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js @@ -0,0 +1,68 @@ +function getBannerValue() { + return document.getElementById("bannerInput").value.trim(); +} + +function getCachePoisoningUrl(includeBanner) { + let url = getUrlForVulnerabilityLevel(); + if (!includeBanner) { + return url; + } + + let banner = getBannerValue(); + if (!banner) { + return url; + } + + return url + "?banner=" + encodeURIComponent(banner); +} + +function updateDiagnostics(xmlHttpRequest) { + document.getElementById("cacheStatus").textContent = + xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + document.getElementById("cacheKey").textContent = + xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; +} + +function updateResponseArea(content) { + document.getElementById("cachePoisoningResponse").innerHTML = content; +} + +function sendCachePoisoningRequest(method, url) { + let xmlHttpRequest = new XMLHttpRequest(); + xmlHttpRequest.onreadystatechange = function () { + if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { + return; + } + + if (xmlHttpRequest.status !== 200) { + alert("Request failed"); + return; + } + + let data = JSON.parse(xmlHttpRequest.responseText); + updateDiagnostics(xmlHttpRequest); + updateResponseArea(data.content); + }; + + xmlHttpRequest.open(method, url, true); + if (method === "GET") { + xmlHttpRequest.setRequestHeader("Content-Type", "application/json"); + } + xmlHttpRequest.send(); +} + +function addEvents() { + document + .getElementById("poisonCacheBtn") + .addEventListener("click", function () { + sendCachePoisoningRequest("GET", getCachePoisoningUrl(true)); + }); + + document + .getElementById("victimRequestBtn") + .addEventListener("click", function () { + sendCachePoisoningRequest("GET", getCachePoisoningUrl(false)); + }); +} + +addEvents(); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java index 08f92c3fe..5b1609aff 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -13,7 +13,11 @@ class CachePoisoningVulnerabilityTest { private static final String LEVEL_1_PATH = "/VulnerableApp/CachePoisoning/LEVEL_1"; + private static final String LEVEL_2_PATH = "/VulnerableApp/CachePoisoning/LEVEL_2"; private static final String ATTACKER_BANNER = "ATUALIZACAO URGENTE FAKE"; + private static final String FILTERED_ATTACKER_BANNER = + "ATUALIZACAO URGENTE FAKE"; + private static final String LEVEL_2_SAFE_HTML_BANNER = "SERVICE STATUS COMPROMISED"; private static final String SAFE_BANNER = "Welcome to the shared cache demo"; private CachePoisoningVulnerability cachePoisoningVulnerability; @@ -93,6 +97,66 @@ void level1ShouldExposePublicCacheHeaders() { assertThat(response.getBody().getContent()).contains(SAFE_BANNER); } + @Test + @DisplayName("Level 2 - Naive filtering still allows poisoning with harmless-looking HTML") + void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, createLevelRequest(LEVEL_2_PATH, null)); + + assertValidResponse(attackerResponse); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()) + .contains("ATUALIZACAO URGENTE FAKE") + .doesNotContain("ATUALIZACAO URGENTE FAKE"; private static final String LEVEL_2_SAFE_HTML_BANNER = "SERVICE STATUS COMPROMISED"; + private static final String LEVEL_3_SHARED_BANNER = "shared-status-page"; + private static final String ATTACKER_HOST = "attacker.example"; + private static final String SAFE_HOST = "cdn.safe.example"; + private static final String ATTACKER_USER = "admin"; + private static final String ESCAPED_SECURE_BANNER = "<b>OWNED</b>"; private static final String SAFE_BANNER = "Welcome to the shared cache demo"; private CachePoisoningVulnerability cachePoisoningVulnerability; @@ -157,6 +166,142 @@ void level2ShouldKeepUsingOnlyRouteAsCacheKey() { assertThat(secondResponse.getBody().getContent()).doesNotContain("SECOND BANNER"); } + @Test + @DisplayName( + "Level 3 - Forwarded host poisons the shared cache even after banner joins the key") + void level3ShouldPoisonSharedCacheThroughUnkeyedForwardedHost() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + LEVEL_3_SHARED_BANNER, + createLevelRequest( + LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + LEVEL_3_SHARED_BANNER, + createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); + + assertValidResponse(attackerResponse); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()) + .contains("https://" + ATTACKER_HOST + "/assets/cache-poisoning-demo.js"); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()) + .contains("https://" + ATTACKER_HOST + "/assets/cache-poisoning-demo.js"); + } + + @Test + @DisplayName("Level 3 - Cache key now includes the banner but still ignores X-Forwarded-Host") + void level3ShouldIncludeBannerInCacheKey() { + ResponseEntity> firstResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + "status-board", + createLevelRequest(LEVEL_3_PATH, "status-board", ATTACKER_HOST, null)); + ResponseEntity> secondResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + "landing-page", + createLevelRequest(LEVEL_3_PATH, "landing-page", SAFE_HOST, null)); + + assertThat( + firstResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_3_PATH + "|banner=status-board"); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_3_PATH + "|banner=landing-page"); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(secondResponse.getBody().getContent()) + .contains("https://" + SAFE_HOST + "/assets/cache-poisoning-demo.js") + .doesNotContain("https://" + ATTACKER_HOST + "/assets/cache-poisoning-demo.js"); + } + + @Test + @DisplayName("Level 4 - Public caching leaks personalized cookie content to another visitor") + void level4ShouldLeakPersonalizedContentAcrossUsers() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + createLevelRequest( + LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + createLevelRequest(LEVEL_4_PATH, null, null, null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_USER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(ATTACKER_USER); + assertThat(victimResponse.getBody().getContent()).doesNotContain("guest"); + } + + @Test + @DisplayName( + "Level 5 - Secure cache policy prevents shared-cache leakage and ignores forwarding headers") + void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { + ResponseEntity> personalizedResponse = + cachePoisoningVulnerability.getSecurePayloadLevel5( + "OWNED", + createLevelRequest( + LEVEL_5_PATH, + "OWNED", + ATTACKER_HOST, + demoUserCookie(ATTACKER_USER))); + ResponseEntity> guestResponse = + cachePoisoningVulnerability.getSecurePayloadLevel5( + null, createLevelRequest(LEVEL_5_PATH, null, null, null)); + + assertValidResponse(personalizedResponse); + assertThat(personalizedResponse.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PRIVATE_NO_STORE); + assertThat( + personalizedResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(personalizedResponse.getBody().getContent()) + .contains(ATTACKER_USER) + .contains(ESCAPED_SECURE_BANNER) + .contains(CachePoisoningVulnerability.TRUSTED_ASSET_HOST) + .doesNotContain(ATTACKER_HOST); + + assertValidResponse(guestResponse); + assertThat( + guestResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(guestResponse.getBody().getContent()).contains("guest"); + assertThat(guestResponse.getBody().getContent()).doesNotContain(ATTACKER_USER); + } + private void assertValidResponse( ResponseEntity> responseEntity) { assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -169,12 +314,27 @@ private MockHttpServletRequest createLevel1Request(String queryString) { } private MockHttpServletRequest createLevelRequest(String path, String queryString) { + return createLevelRequest(path, queryString, null, null); + } + + private MockHttpServletRequest createLevelRequest( + String path, String queryString, String forwardedHost, Cookie cookie) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setMethod("GET"); request.setRequestURI(path); if (queryString != null) { request.setQueryString("banner=" + queryString); } + if (forwardedHost != null) { + request.addHeader(CachePoisoningVulnerability.FORWARDED_HOST_HEADER, forwardedHost); + } + if (cookie != null) { + request.setCookies(cookie); + } return request; } + + private Cookie demoUserCookie(String user) { + return new Cookie(CachePoisoningVulnerability.DEMO_USER_COOKIE, user); + } } From 6049137c5b8ee7e3e9adde192dbb05f443a4ab32 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Tue, 31 Mar 2026 21:32:03 -0300 Subject: [PATCH 05/20] refactor: reuse doGetAjaxCall in levels 1-5 and pass xhr to callbacks --- .../CachePoisoning/LEVEL_1/CachePoisoning.js | 37 ++++----------- .../CachePoisoning/LEVEL_2/CachePoisoning.js | 37 ++++----------- .../CachePoisoning/LEVEL_3/CachePoisoning.js | 41 +++++----------- .../CachePoisoning/LEVEL_4/CachePoisoning.js | 38 +++++---------- .../CachePoisoning/LEVEL_5/CachePoisoning.js | 47 ++++++------------- src/main/resources/static/vulnerableApp.js | 4 +- 6 files changed, 57 insertions(+), 147 deletions(-) diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js index a7d0dd668..5393f17f3 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js @@ -31,40 +31,21 @@ function getLevel1Url(includeBanner) { return url + "?banner=" + encodeURIComponent(banner); } -function updateDiagnostics(xmlHttpRequest) { +function updateDiagnostics(request) { document.getElementById("cacheStatus").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + request.getResponseHeader("X-Cache-Status") || "-"; document.getElementById("cacheKey").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; + request.getResponseHeader("X-Cache-Key") || "-"; } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } -function sendCachePoisoningRequest(method, url) { - let xmlHttpRequest = new XMLHttpRequest(); - xmlHttpRequest.onreadystatechange = function () { - if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { - return; - } - - if (xmlHttpRequest.status !== 200) { - alert("Request failed"); - return; - } - - let data = JSON.parse(xmlHttpRequest.responseText); - updateDiagnostics(xmlHttpRequest); - updateResponseArea(data.content); - clearBannerValue(); - }; - - xmlHttpRequest.open(method, url, true); - if (method === "GET") { - xmlHttpRequest.setRequestHeader("Content-Type", "application/json"); - } - xmlHttpRequest.send(); +function fetchDataCallback(data, request) { + updateDiagnostics(request); + updateResponseArea(data.content); + clearBannerValue(); } function addEvents() { @@ -76,13 +57,13 @@ function addEvents() { return; } - sendCachePoisoningRequest("GET", getLevel1Url(true)); + doGetAjaxCall(fetchDataCallback, getLevel1Url(true), true); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { - sendCachePoisoningRequest("GET", getLevel1Url(false)); + doGetAjaxCall(fetchDataCallback, getLevel1Url(false), true); }); } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js index 759745c2d..3cd80ee23 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js @@ -31,40 +31,21 @@ function getCachePoisoningUrl(includeBanner) { return url + "?banner=" + encodeURIComponent(banner); } -function updateDiagnostics(xmlHttpRequest) { +function updateDiagnostics(request) { document.getElementById("cacheStatus").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + request.getResponseHeader("X-Cache-Status") || "-"; document.getElementById("cacheKey").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; + request.getResponseHeader("X-Cache-Key") || "-"; } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } -function sendCachePoisoningRequest(method, url) { - let xmlHttpRequest = new XMLHttpRequest(); - xmlHttpRequest.onreadystatechange = function () { - if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { - return; - } - - if (xmlHttpRequest.status !== 200) { - alert("Request failed"); - return; - } - - let data = JSON.parse(xmlHttpRequest.responseText); - updateDiagnostics(xmlHttpRequest); - updateResponseArea(data.content); - clearBannerValue(); - }; - - xmlHttpRequest.open(method, url, true); - if (method === "GET") { - xmlHttpRequest.setRequestHeader("Content-Type", "application/json"); - } - xmlHttpRequest.send(); +function fetchDataCallback(data, request) { + updateDiagnostics(request); + updateResponseArea(data.content); + clearBannerValue(); } function addEvents() { @@ -76,13 +57,13 @@ function addEvents() { return; } - sendCachePoisoningRequest("GET", getCachePoisoningUrl(true)); + doGetAjaxCall(fetchDataCallback, getCachePoisoningUrl(true), true); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { - sendCachePoisoningRequest("GET", getCachePoisoningUrl(false)); + doGetAjaxCall(fetchDataCallback, getCachePoisoningUrl(false), true); }); } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js index eb4046737..895360bfd 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js @@ -30,44 +30,25 @@ function buildLevel3Url() { ); } -function updateDiagnostics(xmlHttpRequest) { +function updateDiagnostics(request) { document.getElementById("cacheStatus").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + request.getResponseHeader("X-Cache-Status") || "-"; document.getElementById("cacheKey").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; + request.getResponseHeader("X-Cache-Key") || "-"; document.getElementById("cacheControl").textContent = - xmlHttpRequest.getResponseHeader("Cache-Control") || "-"; + request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = - xmlHttpRequest.getResponseHeader("Vary") || "-"; + request.getResponseHeader("Vary") || "-"; } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } -function sendCachePoisoningRequest(method, url, forwardedHost) { - let xmlHttpRequest = new XMLHttpRequest(); - xmlHttpRequest.onreadystatechange = function () { - if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { - return; - } - - if (xmlHttpRequest.status !== 200) { - alert("Request failed"); - return; - } - - let data = JSON.parse(xmlHttpRequest.responseText); - updateDiagnostics(xmlHttpRequest); - updateResponseArea(data.content); - clearInputs(); - }; - - xmlHttpRequest.open(method, url, true); - if (forwardedHost) { - xmlHttpRequest.setRequestHeader("X-Forwarded-Host", forwardedHost); - } - xmlHttpRequest.send(); +function fetchDataCallback(data, request) { + updateDiagnostics(request); + updateResponseArea(data.content); + clearInputs(); } function addEvents() { @@ -80,7 +61,7 @@ function addEvents() { return; } - sendCachePoisoningRequest("GET", buildLevel3Url(), forwardedHost); + doGetAjaxCall(fetchDataCallback, buildLevel3Url(), true, { "X-Forwarded-Host": forwardedHost}); }); document @@ -91,7 +72,7 @@ function addEvents() { return; } - sendCachePoisoningRequest("GET", buildLevel3Url(), null); + doGetAjaxCall(fetchDataCallback, buildLevel3Url(), true); }); } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js index 4eef26e65..0ff93c2e2 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js @@ -27,41 +27,25 @@ function clearDemoUserCookie() { "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"; } -function updateDiagnostics(xmlHttpRequest) { +function updateDiagnostics(request) { document.getElementById("cacheStatus").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + request.getResponseHeader("X-Cache-Status") || "-"; document.getElementById("cacheKey").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; + request.getResponseHeader("X-Cache-Key") || "-"; document.getElementById("cacheControl").textContent = - xmlHttpRequest.getResponseHeader("Cache-Control") || "-"; + request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = - xmlHttpRequest.getResponseHeader("Vary") || "-"; + request.getResponseHeader("Vary") || "-"; } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } -function sendCachePoisoningRequest(method, url) { - let xmlHttpRequest = new XMLHttpRequest(); - xmlHttpRequest.onreadystatechange = function () { - if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { - return; - } - - if (xmlHttpRequest.status !== 200) { - alert("Request failed"); - return; - } - - let data = JSON.parse(xmlHttpRequest.responseText); - updateDiagnostics(xmlHttpRequest); - updateResponseArea(data.content); - clearDemoUserValue(); - }; - - xmlHttpRequest.open(method, url, true); - xmlHttpRequest.send(); +function fetchDataCallback(data, request) { + updateDiagnostics(request); + updateResponseArea(data.content); + clearDemoUserValue(); } function addEvents() { @@ -74,14 +58,14 @@ function addEvents() { } setDemoUserCookie(demoUser); - sendCachePoisoningRequest("GET", getUrlForVulnerabilityLevel()); + doGetAjaxCall(fetchDataCallback, getUrlForVulnerabilityLevel(), true); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { clearDemoUserCookie(); - sendCachePoisoningRequest("GET", getUrlForVulnerabilityLevel()); + doGetAjaxCall(fetchDataCallback, getUrlForVulnerabilityLevel(), true); }); } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js index 8ca3dc95d..5bab02819 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js @@ -47,44 +47,25 @@ function buildLevel5Url() { return url + "?banner=" + encodeURIComponent(banner); } -function updateDiagnostics(xmlHttpRequest) { +function updateDiagnostics(request) { document.getElementById("cacheStatus").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Status") || "-"; + request.getResponseHeader("X-Cache-Status") || "-"; document.getElementById("cacheKey").textContent = - xmlHttpRequest.getResponseHeader("X-Cache-Key") || "-"; + request.getResponseHeader("X-Cache-Key") || "-"; document.getElementById("cacheControl").textContent = - xmlHttpRequest.getResponseHeader("Cache-Control") || "-"; + request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = - xmlHttpRequest.getResponseHeader("Vary") || "-"; + request.getResponseHeader("Vary") || "-"; } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } -function sendCachePoisoningRequest(method, url, forwardedHost) { - let xmlHttpRequest = new XMLHttpRequest(); - xmlHttpRequest.onreadystatechange = function () { - if (xmlHttpRequest.readyState !== XMLHttpRequest.DONE) { - return; - } - - if (xmlHttpRequest.status !== 200) { - alert("Request failed"); - return; - } - - let data = JSON.parse(xmlHttpRequest.responseText); - updateDiagnostics(xmlHttpRequest); - updateResponseArea(data.content); - clearInputs(); - }; - - xmlHttpRequest.open(method, url, true); - if (forwardedHost) { - xmlHttpRequest.setRequestHeader("X-Forwarded-Host", forwardedHost); - } - xmlHttpRequest.send(); +function fetchDataCallback(data, request) { + updateDiagnostics(request); + updateResponseArea(data.content); + clearInputs(); } function addEvents() { @@ -92,15 +73,17 @@ function addEvents() { .getElementById("poisonCacheBtn") .addEventListener("click", function () { let demoUser = requireDemoUserValue(); + let forwardedHost = getForwardedHostValue(); if (!demoUser) { return; } setDemoUserCookie(demoUser); - sendCachePoisoningRequest( - "GET", + doGetAjaxCall( + fetchDataCallback, buildLevel5Url(), - getForwardedHostValue() + true, + forwardedHost ? { "X-Forwarded-Host": forwardedHost } : {} ); }); @@ -108,7 +91,7 @@ function addEvents() { .getElementById("victimRequestBtn") .addEventListener("click", function () { clearDemoUserCookie(); - sendCachePoisoningRequest("GET", buildLevel5Url(), null); + doGetAjaxCall(fetchDataCallback, buildLevel5Url(), true); }); } diff --git a/src/main/resources/static/vulnerableApp.js b/src/main/resources/static/vulnerableApp.js index c79355a5a..a914294cd 100644 --- a/src/main/resources/static/vulnerableApp.js +++ b/src/main/resources/static/vulnerableApp.js @@ -226,9 +226,9 @@ function genericResponseHandler(xmlHttpRequest, callBack, isJson) { // XMLHttpRequest.DONE == 4 if (xmlHttpRequest.status == 200 || xmlHttpRequest.status == 401) { if (isJson) { - callBack(JSON.parse(xmlHttpRequest.responseText)); + callBack(JSON.parse(xmlHttpRequest.responseText), xmlHttpRequest); } else { - callBack(xmlHttpRequest.responseText); + callBack(xmlHttpRequest.responseText, xmlHttpRequest); } } else if (xmlHttpRequest.status == 400) { alert("There was an error 400"); From 3bae76d93fafd411407d5cbd14e9e2b250650f39 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Thu, 2 Apr 2026 21:37:40 -0300 Subject: [PATCH 06/20] feat: add reset cache button for all levels --- .../CachePoisoningVulnerability.java | 82 ++++- .../CachePoisoning/LEVEL_1/CachePoisoning.css | 128 +++++++- .../LEVEL_1/CachePoisoning.html | 29 +- .../CachePoisoning/LEVEL_1/CachePoisoning.js | 98 +++++- .../LEVEL_2/CachePoisoning.html | 29 +- .../CachePoisoning/LEVEL_2/CachePoisoning.js | 98 +++++- .../LEVEL_3/CachePoisoning.html | 49 +-- .../CachePoisoning/LEVEL_3/CachePoisoning.js | 99 +++++- .../LEVEL_4/CachePoisoning.html | 33 +- .../CachePoisoning/LEVEL_4/CachePoisoning.js | 89 +++++- .../LEVEL_5/CachePoisoning.html | 73 +++-- .../CachePoisoning/LEVEL_5/CachePoisoning.js | 96 +++++- .../CachePoisoningVulnerabilityTest.java | 299 +++++++++++++++++- 13 files changed, 1045 insertions(+), 157 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 932276b91..e87fa2b5c 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -25,18 +25,26 @@ public class CachePoisoningVulnerability { static final String BANNER_QUERY_PARAMETER = "banner"; + static final String RESET_CACHE_QUERY_PARAMETER = "resetCache"; + static final String FORWARDED_HOST_HEADER = "X-Forwarded-Host"; - static final String DEMO_USER_COOKIE = "demo_user"; static final String CACHE_STATUS_HEADER = "X-Cache-Status"; static final String CACHE_KEY_HEADER = "X-Cache-Key"; + + static final String EMPTY_CACHE_KEY = "-"; static final String CACHE_STATUS_HIT = "HIT"; static final String CACHE_STATUS_MISS = "MISS"; static final String CACHE_CONTROL_PUBLIC = "public, max-age=60"; static final String CACHE_CONTROL_PRIVATE_NO_STORE = "private, no-store"; + static final String DEFAULT_BANNER = "Welcome to the shared cache demo"; + static final String DEFAULT_UNTRUSTED_HOST = "cdn.vulnerableapp.local"; static final String TRUSTED_ASSET_HOST = "static.vulnerableapp.local"; + + static final String DEMO_USER_COOKIE = "demo_user"; static final String DEFAULT_DEMO_USER = "guest"; + static final Duration DEFAULT_TTL = Duration.ofSeconds(60); private static final Pattern SCRIPT_BLOCK_PATTERN = Pattern.compile("(?is)<\\s*script\\b[^>]*>.*?<\\s*/\\s*script\\s*>"); @@ -55,7 +63,19 @@ public class CachePoisoningVulnerability { htmlTemplate = "LEVEL_1/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam( + value = RESET_CACHE_QUERY_PARAMETER, + required = false, + defaultValue = "false") + boolean resetCache, HttpServletRequest request) { + if (resetCache) { + clearCache(); + return buildResetCacheResponse( + buildLevel1Response(null), + CACHE_CONTROL_PUBLIC + ); + } String responseContent = buildLevel1Response(banner); return buildCachedResponse( buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); @@ -70,7 +90,19 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_2/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam( + value = RESET_CACHE_QUERY_PARAMETER, + required = false, + defaultValue = "false") + boolean resetCache, HttpServletRequest request) { + if (resetCache) { + clearCache(); + return buildResetCacheResponse( + buildLevel2Response(null), + CACHE_CONTROL_PUBLIC + ); + } String responseContent = buildLevel2Response(banner); return buildCachedResponse( buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); @@ -85,7 +117,19 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_3/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam( + value = RESET_CACHE_QUERY_PARAMETER, + required = false, + defaultValue = "false") + boolean resetCache, HttpServletRequest request) { + if (resetCache) { + clearCache(); + return buildResetCacheResponse( + buildLevel3Response(null, request), + CACHE_CONTROL_PUBLIC + ); + } String responseContent = buildLevel3Response(banner, request); return buildCachedResponse( buildRouteAndBannerCacheKey(request, banner), @@ -102,7 +146,19 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_4/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel4( + @RequestParam( + value = RESET_CACHE_QUERY_PARAMETER, + required = false, + defaultValue = "false") + boolean resetCache, HttpServletRequest request) { + if (resetCache) { + clearCache(); + return buildResetCacheResponse( + buildLevel4Response(request), + CACHE_CONTROL_PUBLIC + ); + } String responseContent = buildLevel4Response(request); return buildCachedResponse( buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); @@ -118,7 +174,19 @@ public ResponseEntity> getVulnerablePay variant = Variant.SECURE) public ResponseEntity> getSecurePayloadLevel5( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam( + value = RESET_CACHE_QUERY_PARAMETER, + required = false, + defaultValue = "false") + boolean resetCache, HttpServletRequest request) { + if (resetCache) { + clearCache(); + return buildResetCacheResponse( + buildLevel5Response(null, request), + CACHE_CONTROL_PRIVATE_NO_STORE + ); + } String responseContent = buildLevel5Response(banner, request); return buildCachedResponse( buildPrivateResponseKey(request), @@ -255,6 +323,18 @@ private ResponseEntity> buildCachedResp cacheHit ? cachedResponse.getContent() : responseContent, true)); } + private ResponseEntity> buildResetCacheResponse( + String responseContent, String cacheControl) { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setCacheControl(cacheControl); + responseHeaders.add(CACHE_STATUS_HEADER, CachePoisoningVulnerability.CACHE_STATUS_MISS); + responseHeaders.add(CACHE_KEY_HEADER, CachePoisoningVulnerability.EMPTY_CACHE_KEY); + + return ResponseEntity.ok() + .headers(responseHeaders) + .body(new GenericVulnerabilityResponseBean<>(responseContent, true)); + } + private String buildRouteOnlyCacheKey(HttpServletRequest request) { return request.getRequestURI(); } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css index ac1ce5a2a..1cd15ff0b 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css @@ -42,15 +42,21 @@ } .cache-poisoning-form { + width: 100%; +} + +.cache-poisoning-entry-row { display: flex; - flex-wrap: wrap; align-items: flex-end; - justify-content: center; gap: 10px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px dotted black; } .cache-poisoning-field { flex: 1 1 320px; + min-width: 0; display: flex; flex-direction: column; align-items: flex-start; @@ -81,28 +87,82 @@ .cache-poisoning-actions { display: flex; + align-items: center; + justify-content: space-between; flex-wrap: wrap; - justify-content: center; + width: 100%; + gap: 12px; +} + +.cache-poisoning-action-group { + display: flex; + align-items: center; gap: 8px; + flex-wrap: nowrap; +} + +.cache-poisoning-action-group-inline { + flex: 0 0 145px; + width: 145px; } -.cache-poisoning-actions button { +.cache-poisoning-action-group-right { + margin-left: auto; + flex: 0 0 145px; + width: 145px; + overflow-x: auto; + padding-bottom: 2px; +} + +.cache-poisoning-action-group button { + appearance: none; + -webkit-appearance: none; background: blueviolet; - display: inline-block; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; padding: 8px 10px; - margin: 2px; border: 2px solid transparent; border-radius: 3px; transition: 0.2s opacity; color: #fff; font-size: 12px; + white-space: nowrap; } -.cache-poisoning-actions button:hover { +.cache-poisoning-action-group button:hover:not(:disabled) { opacity: 0.9; cursor: pointer; } +#resetCacheBtn { + background: #e7dcc2; + color: black; + border-color: black; +} + +#poisonCacheBtn, +#victimRequestBtn { + width: 145px; + min-width: 145px; + max-width: 145px; + flex: 0 0 145px; + box-sizing: border-box; + padding-left: 6px; + padding-right: 6px; + font-size: 11px; + text-align: center; +} + +.cache-poisoning-action-group button:disabled { + background: #c7c7c7; + color: #555; + border-color: #8e8e8e; + opacity: 1; + cursor: not-allowed; +} + .cache-poisoning-diagnostics { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -135,6 +195,32 @@ font-size: 12px; } +.cache-status-indicator { + font-weight: bold; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +.cache-status-hit { + background: #edf9f0; + border-color: #7db78d; + color: #1f5c35; +} + +.cache-status-miss { + background: #fff1e8; + border-color: #d7a084; + color: #8c4523; +} + +.cache-status-neutral { + background: rgba(255, 255, 255, 0.85); + border-color: black; + color: black; +} + .cache-poisoning-output { min-height: 110px; padding: 12px; @@ -166,16 +252,42 @@ } .cache-poisoning-form { + width: 100%; + } + + .cache-poisoning-entry-row { + flex-direction: column; + align-items: stretch; + margin-bottom: 8px; + } + + .cache-poisoning-entry-row, + .cache-poisoning-actions, + .cache-poisoning-action-group, + .cache-poisoning-action-group-inline, + .cache-poisoning-action-group-right { + width: 100%; + } + + .cache-poisoning-entry-row, + .cache-poisoning-actions, + .cache-poisoning-action-group { flex-direction: column; align-items: stretch; } + .cache-poisoning-action-group-right { + margin-left: 0; + padding-bottom: 0; + overflow-x: visible; + } + .cache-poisoning-field { align-items: stretch; } .cache-poisoning-input, - .cache-poisoning-actions button { + .cache-poisoning-action-group button { width: 100%; } } diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html index 6aa78f9da..2cbdc9579 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html @@ -9,17 +9,26 @@

    Cache Poisoning

    Controls
    -
    - - +
    +
    + + +
    +
    + +
    - - +
    + +
    +
    + +
    @@ -29,7 +38,7 @@
    Cache Signals
    Cache status - - + -
    Cache key diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js index 5393f17f3..7da4e0d45 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js @@ -17,31 +17,80 @@ function requireBannerValue() { return null; } -function getLevel1Url(includeBanner) { +function getNoBrowserCacheHeaders(headers = {}) { + return { + ...headers, + "Cache-Control": "no-cache", + }; +} + +function getLevel1Url(includeBanner, resetCache = false) { let url = getUrlForVulnerabilityLevel(); - if (!includeBanner) { - return url; + let queryParams = new URLSearchParams(); + + if (includeBanner) { + let banner = getBannerValue(); + if (banner) { + queryParams.set("banner", banner); + } } - let banner = getBannerValue(); - if (!banner) { - return url; + if (resetCache) { + queryParams.set("resetCache", "true"); } - return url + "?banner=" + encodeURIComponent(banner); + let queryString = queryParams.toString(); + return queryString ? url + "?" + queryString : url; } function updateDiagnostics(request) { - document.getElementById("cacheStatus").textContent = - request.getResponseHeader("X-Cache-Status") || "-"; - document.getElementById("cacheKey").textContent = - request.getResponseHeader("X-Cache-Key") || "-"; + let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; + updateResetCacheButton(cacheStatus, cacheKey); } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } +function updateCacheStatusIndicator(cacheStatus) { + let cacheStatusElement = document.getElementById("cacheStatus"); + let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); + + cacheStatusElement.textContent = cacheStatus; + cacheStatusElement.classList.remove( + "cache-status-hit", + "cache-status-miss", + "cache-status-neutral" + ); + + if (normalizedCacheStatus === "HIT") { + cacheStatusElement.classList.add("cache-status-hit"); + return; + } + + if (normalizedCacheStatus === "MISS") { + cacheStatusElement.classList.add("cache-status-miss"); + return; + } + + cacheStatusElement.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + let resetCacheButton = document.getElementById("resetCacheBtn"); + let hasCachedRequest = + cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + + resetCacheButton.disabled = !hasCachedRequest; + resetCacheButton.title = hasCachedRequest + ? "Cached request available" + : "No cached request yet"; +} + function fetchDataCallback(data, request) { updateDiagnostics(request); updateResponseArea(data.content); @@ -49,6 +98,17 @@ function fetchDataCallback(data, request) { } function addEvents() { + document + .getElementById("resetCacheBtn") + .addEventListener("click", function () { + doGetAjaxCall( + fetchDataCallback, + getLevel1Url(false, true), + true, + getNoBrowserCacheHeaders() + ); + }); + document .getElementById("poisonCacheBtn") .addEventListener("click", function () { @@ -57,14 +117,26 @@ function addEvents() { return; } - doGetAjaxCall(fetchDataCallback, getLevel1Url(true), true); + doGetAjaxCall( + fetchDataCallback, + getLevel1Url(true), + true, + getNoBrowserCacheHeaders() + ); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { - doGetAjaxCall(fetchDataCallback, getLevel1Url(false), true); + doGetAjaxCall( + fetchDataCallback, + getLevel1Url(false), + true, + getNoBrowserCacheHeaders() + ); }); } +updateCacheStatusIndicator("-"); +updateResetCacheButton("-", "-"); addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html index 064b9ab4e..e9301f57a 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html @@ -10,17 +10,26 @@

    Cache Poisoning

    Controls
    -
    - - +
    +
    + + +
    +
    + +
    - - +
    + +
    +
    + +
    @@ -30,7 +39,7 @@
    Cache Signals
    Cache status - - + -
    Cache key diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js index 3cd80ee23..332046b92 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js @@ -17,31 +17,80 @@ function requireBannerValue() { return null; } -function getCachePoisoningUrl(includeBanner) { +function getNoBrowserCacheHeaders(headers = {}) { + return { + ...headers, + "Cache-Control": "no-cache", + }; +} + +function getCachePoisoningUrl(includeBanner, resetCache = false) { let url = getUrlForVulnerabilityLevel(); - if (!includeBanner) { - return url; + let queryParams = new URLSearchParams(); + + if (includeBanner) { + let banner = getBannerValue(); + if (banner) { + queryParams.set("banner", banner); + } } - let banner = getBannerValue(); - if (!banner) { - return url; + if (resetCache) { + queryParams.set("resetCache", "true"); } - return url + "?banner=" + encodeURIComponent(banner); + let queryString = queryParams.toString(); + return queryString ? url + "?" + queryString : url; } function updateDiagnostics(request) { - document.getElementById("cacheStatus").textContent = - request.getResponseHeader("X-Cache-Status") || "-"; - document.getElementById("cacheKey").textContent = - request.getResponseHeader("X-Cache-Key") || "-"; + let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; + updateResetCacheButton(cacheStatus, cacheKey); } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } +function updateCacheStatusIndicator(cacheStatus) { + let cacheStatusElement = document.getElementById("cacheStatus"); + let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); + + cacheStatusElement.textContent = cacheStatus; + cacheStatusElement.classList.remove( + "cache-status-hit", + "cache-status-miss", + "cache-status-neutral" + ); + + if (normalizedCacheStatus === "HIT") { + cacheStatusElement.classList.add("cache-status-hit"); + return; + } + + if (normalizedCacheStatus === "MISS") { + cacheStatusElement.classList.add("cache-status-miss"); + return; + } + + cacheStatusElement.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + let resetCacheButton = document.getElementById("resetCacheBtn"); + let hasCachedRequest = + cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + + resetCacheButton.disabled = !hasCachedRequest; + resetCacheButton.title = hasCachedRequest + ? "Cached request available" + : "No cached request yet"; +} + function fetchDataCallback(data, request) { updateDiagnostics(request); updateResponseArea(data.content); @@ -49,6 +98,17 @@ function fetchDataCallback(data, request) { } function addEvents() { + document + .getElementById("resetCacheBtn") + .addEventListener("click", function () { + doGetAjaxCall( + fetchDataCallback, + getCachePoisoningUrl(false, true), + true, + getNoBrowserCacheHeaders() + ); + }); + document .getElementById("poisonCacheBtn") .addEventListener("click", function () { @@ -57,14 +117,26 @@ function addEvents() { return; } - doGetAjaxCall(fetchDataCallback, getCachePoisoningUrl(true), true); + doGetAjaxCall( + fetchDataCallback, + getCachePoisoningUrl(true), + true, + getNoBrowserCacheHeaders() + ); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { - doGetAjaxCall(fetchDataCallback, getCachePoisoningUrl(false), true); + doGetAjaxCall( + fetchDataCallback, + getCachePoisoningUrl(false), + true, + getNoBrowserCacheHeaders() + ); }); } +updateCacheStatusIndicator("-"); +updateResetCacheButton("-", "-"); addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html index af719e843..51c3bca70 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html @@ -10,27 +10,38 @@

    Cache Poisoning

    Controls
    -
    - - +
    +
    + + +
    -
    - - +
    +
    + + +
    +
    + +
    - - +
    + +
    +
    + +
    @@ -40,7 +51,7 @@
    Cache Signals
    Cache status - - + -
    Cache key diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js index 895360bfd..d16fcc392 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js @@ -22,29 +22,81 @@ function requireValue(inputId, label) { return null; } -function buildLevel3Url() { - return ( - getUrlForVulnerabilityLevel() + - "?banner=" + - encodeURIComponent(getBannerValue()) - ); +function getNoBrowserCacheHeaders(headers = {}) { + return { + ...headers, + "Cache-Control": "no-cache", + }; +} + +function buildLevel3Url(includeBanner, resetCache = false) { + let url = getUrlForVulnerabilityLevel(); + let queryParams = new URLSearchParams(); + + if (includeBanner) { + queryParams.set("banner", getBannerValue()); + } + + if (resetCache) { + queryParams.set("resetCache", "true"); + } + + let queryString = queryParams.toString(); + return queryString ? url + "?" + queryString : url; } function updateDiagnostics(request) { - document.getElementById("cacheStatus").textContent = - request.getResponseHeader("X-Cache-Status") || "-"; - document.getElementById("cacheKey").textContent = - request.getResponseHeader("X-Cache-Key") || "-"; + let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; document.getElementById("cacheControl").textContent = request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = request.getResponseHeader("Vary") || "-"; + updateResetCacheButton(cacheStatus, cacheKey); } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } +function updateCacheStatusIndicator(cacheStatus) { + let cacheStatusElement = document.getElementById("cacheStatus"); + let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); + + cacheStatusElement.textContent = cacheStatus; + cacheStatusElement.classList.remove( + "cache-status-hit", + "cache-status-miss", + "cache-status-neutral" + ); + + if (normalizedCacheStatus === "HIT") { + cacheStatusElement.classList.add("cache-status-hit"); + return; + } + + if (normalizedCacheStatus === "MISS") { + cacheStatusElement.classList.add("cache-status-miss"); + return; + } + + cacheStatusElement.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + let resetCacheButton = document.getElementById("resetCacheBtn"); + let hasCachedRequest = + cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + + resetCacheButton.disabled = !hasCachedRequest; + resetCacheButton.title = hasCachedRequest + ? "Cached request available" + : "No cached request yet"; +} + function fetchDataCallback(data, request) { updateDiagnostics(request); updateResponseArea(data.content); @@ -52,6 +104,17 @@ function fetchDataCallback(data, request) { } function addEvents() { + document + .getElementById("resetCacheBtn") + .addEventListener("click", function () { + doGetAjaxCall( + fetchDataCallback, + buildLevel3Url(false, true), + true, + getNoBrowserCacheHeaders() + ); + }); + document .getElementById("poisonCacheBtn") .addEventListener("click", function () { @@ -61,7 +124,12 @@ function addEvents() { return; } - doGetAjaxCall(fetchDataCallback, buildLevel3Url(), true, { "X-Forwarded-Host": forwardedHost}); + doGetAjaxCall( + fetchDataCallback, + buildLevel3Url(true), + true, + getNoBrowserCacheHeaders({ "X-Forwarded-Host": forwardedHost }) + ); }); document @@ -72,8 +140,15 @@ function addEvents() { return; } - doGetAjaxCall(fetchDataCallback, buildLevel3Url(), true); + doGetAjaxCall( + fetchDataCallback, + buildLevel3Url(true), + true, + getNoBrowserCacheHeaders() + ); }); } +updateCacheStatusIndicator("-"); +updateResetCacheButton("-", "-"); addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html index 835505479..1657c7349 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html @@ -10,19 +10,28 @@

    Cache Poisoning

    Controls
    -
    - - +
    +
    + + +
    +
    + +
    - - +
    + +
    +
    + +
    @@ -32,7 +41,7 @@
    Cache Signals
    Cache status - - + -
    Cache key diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js index 0ff93c2e2..2f3ec8434 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js @@ -27,21 +27,74 @@ function clearDemoUserCookie() { "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"; } +function getNoBrowserCacheHeaders(headers = {}) { + return { + ...headers, + "Cache-Control": "no-cache", + }; +} + +function getLevel4Url(resetCache = false) { + let url = getUrlForVulnerabilityLevel(); + if (!resetCache) { + return url; + } + + return url + "?resetCache=true"; +} + function updateDiagnostics(request) { - document.getElementById("cacheStatus").textContent = - request.getResponseHeader("X-Cache-Status") || "-"; - document.getElementById("cacheKey").textContent = - request.getResponseHeader("X-Cache-Key") || "-"; + let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; document.getElementById("cacheControl").textContent = request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = request.getResponseHeader("Vary") || "-"; + updateResetCacheButton(cacheStatus, cacheKey); } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } +function updateCacheStatusIndicator(cacheStatus) { + let cacheStatusElement = document.getElementById("cacheStatus"); + let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); + + cacheStatusElement.textContent = cacheStatus; + cacheStatusElement.classList.remove( + "cache-status-hit", + "cache-status-miss", + "cache-status-neutral" + ); + + if (normalizedCacheStatus === "HIT") { + cacheStatusElement.classList.add("cache-status-hit"); + return; + } + + if (normalizedCacheStatus === "MISS") { + cacheStatusElement.classList.add("cache-status-miss"); + return; + } + + cacheStatusElement.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + let resetCacheButton = document.getElementById("resetCacheBtn"); + let hasCachedRequest = + cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + + resetCacheButton.disabled = !hasCachedRequest; + resetCacheButton.title = hasCachedRequest + ? "Cached request available" + : "No cached request yet"; +} + function fetchDataCallback(data, request) { updateDiagnostics(request); updateResponseArea(data.content); @@ -49,6 +102,18 @@ function fetchDataCallback(data, request) { } function addEvents() { + document + .getElementById("resetCacheBtn") + .addEventListener("click", function () { + clearDemoUserCookie(); + doGetAjaxCall( + fetchDataCallback, + getLevel4Url(true), + true, + getNoBrowserCacheHeaders() + ); + }); + document .getElementById("poisonCacheBtn") .addEventListener("click", function () { @@ -58,15 +123,27 @@ function addEvents() { } setDemoUserCookie(demoUser); - doGetAjaxCall(fetchDataCallback, getUrlForVulnerabilityLevel(), true); + doGetAjaxCall( + fetchDataCallback, + getLevel4Url(), + true, + getNoBrowserCacheHeaders() + ); }); document .getElementById("victimRequestBtn") .addEventListener("click", function () { clearDemoUserCookie(); - doGetAjaxCall(fetchDataCallback, getUrlForVulnerabilityLevel(), true); + doGetAjaxCall( + fetchDataCallback, + getLevel4Url(), + true, + getNoBrowserCacheHeaders() + ); }); } +updateCacheStatusIndicator("-"); +updateResetCacheButton("-", "-"); addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html index 2c09cd7cb..62102f0a9 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html @@ -10,39 +10,52 @@

    Cache Poisoning

    Controls
    -
    - - +
    +
    + + +
    -
    - - +
    +
    + + +
    -
    - - +
    +
    + + +
    +
    + +
    - - +
    + +
    +
    + +
    @@ -52,7 +65,7 @@
    Cache Signals
    Cache status - - + -
    Cache key diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js index 5bab02819..a0a6b5f7d 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js @@ -37,31 +37,84 @@ function clearDemoUserCookie() { "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"; } -function buildLevel5Url() { +function getNoBrowserCacheHeaders(headers = {}) { + return { + ...headers, + "Cache-Control": "no-cache", + }; +} + +function buildLevel5Url(includeBanner = true, resetCache = false) { let url = getUrlForVulnerabilityLevel(); - let banner = getBannerValue(); - if (!banner) { - return url; + let queryParams = new URLSearchParams(); + + if (includeBanner) { + let banner = getBannerValue(); + if (banner) { + queryParams.set("banner", banner); + } + } + + if (resetCache) { + queryParams.set("resetCache", "true"); } - return url + "?banner=" + encodeURIComponent(banner); + let queryString = queryParams.toString(); + return queryString ? url + "?" + queryString : url; } function updateDiagnostics(request) { - document.getElementById("cacheStatus").textContent = - request.getResponseHeader("X-Cache-Status") || "-"; - document.getElementById("cacheKey").textContent = - request.getResponseHeader("X-Cache-Key") || "-"; + let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; document.getElementById("cacheControl").textContent = request.getResponseHeader("Cache-Control") || "-"; document.getElementById("varyHeader").textContent = request.getResponseHeader("Vary") || "-"; + updateResetCacheButton(cacheStatus, cacheKey); } function updateResponseArea(content) { document.getElementById("cachePoisoningResponse").innerHTML = content; } +function updateCacheStatusIndicator(cacheStatus) { + let cacheStatusElement = document.getElementById("cacheStatus"); + let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); + + cacheStatusElement.textContent = cacheStatus; + cacheStatusElement.classList.remove( + "cache-status-hit", + "cache-status-miss", + "cache-status-neutral" + ); + + if (normalizedCacheStatus === "HIT") { + cacheStatusElement.classList.add("cache-status-hit"); + return; + } + + if (normalizedCacheStatus === "MISS") { + cacheStatusElement.classList.add("cache-status-miss"); + return; + } + + cacheStatusElement.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + let resetCacheButton = document.getElementById("resetCacheBtn"); + let hasCachedRequest = + cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + + resetCacheButton.disabled = !hasCachedRequest; + resetCacheButton.title = hasCachedRequest + ? "Cached request available" + : "No cached request yet"; +} + function fetchDataCallback(data, request) { updateDiagnostics(request); updateResponseArea(data.content); @@ -69,6 +122,18 @@ function fetchDataCallback(data, request) { } function addEvents() { + document + .getElementById("resetCacheBtn") + .addEventListener("click", function () { + clearDemoUserCookie(); + doGetAjaxCall( + fetchDataCallback, + buildLevel5Url(false, true), + true, + getNoBrowserCacheHeaders() + ); + }); + document .getElementById("poisonCacheBtn") .addEventListener("click", function () { @@ -83,7 +148,9 @@ function addEvents() { fetchDataCallback, buildLevel5Url(), true, - forwardedHost ? { "X-Forwarded-Host": forwardedHost } : {} + getNoBrowserCacheHeaders( + forwardedHost ? { "X-Forwarded-Host": forwardedHost } : {} + ) ); }); @@ -91,8 +158,15 @@ function addEvents() { .getElementById("victimRequestBtn") .addEventListener("click", function () { clearDemoUserCookie(); - doGetAjaxCall(fetchDataCallback, buildLevel5Url(), true); + doGetAjaxCall( + fetchDataCallback, + buildLevel5Url(), + true, + getNoBrowserCacheHeaders() + ); }); } +updateCacheStatusIndicator("-"); +updateResetCacheButton("-", "-"); addEvents(); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java index 1f3579a75..f71c42966 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -42,10 +42,10 @@ void setUp() { void level1ShouldPoisonSharedCacheAcrossRequests() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, createLevel1Request("poison")); + ATTACKER_BANNER, false, createLevel1Request("poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, false, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat( @@ -69,10 +69,10 @@ void level1ShouldPoisonSharedCacheAcrossRequests() { void level1ShouldUseOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "FIRST BANNER", createLevel1Request("FIRST")); + "FIRST BANNER", false, createLevel1Request("FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "SECOND BANNER", createLevel1Request("SECOND")); + "SECOND BANNER", false, createLevel1Request("SECOND")); assertThat( firstResponse @@ -98,7 +98,7 @@ void level1ShouldUseOnlyRouteAsCacheKey() { void level1ShouldExposePublicCacheHeaders() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, false, createLevel1Request(null)); assertValidResponse(response); assertThat(response.getHeaders().getCacheControl()) @@ -106,15 +106,60 @@ void level1ShouldExposePublicCacheHeaders() { assertThat(response.getBody().getContent()).contains(SAFE_BANNER); } + @Test + @DisplayName( + "Level 1 - Reset parameter clears the shared cache and returns the default response without storing it") + void level1ShouldResetCacheWhenRequested() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + ATTACKER_BANNER, false, createLevel1Request("poison")); + ResponseEntity> resetResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, true, createLevel1Request(null, true)); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, false, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); + + assertValidResponse(resetResponse); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); + assertThat(resetResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain(ATTACKER_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain(ATTACKER_BANNER); + } + @Test @DisplayName("Level 2 - Naive filtering still allows poisoning with harmless-looking HTML") void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); + FILTERED_ATTACKER_BANNER, + false, + createLevelRequest(LEVEL_2_PATH, "poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, createLevelRequest(LEVEL_2_PATH, null)); + null, false, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat( @@ -142,10 +187,14 @@ void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { void level2ShouldKeepUsingOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - LEVEL_2_SAFE_HTML_BANNER, createLevelRequest(LEVEL_2_PATH, "FIRST")); + LEVEL_2_SAFE_HTML_BANNER, + false, + createLevelRequest(LEVEL_2_PATH, "FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - "SECOND BANNER", createLevelRequest(LEVEL_2_PATH, "SECOND")); + "SECOND BANNER", + false, + createLevelRequest(LEVEL_2_PATH, "SECOND")); assertThat( firstResponse @@ -166,6 +215,51 @@ void level2ShouldKeepUsingOnlyRouteAsCacheKey() { assertThat(secondResponse.getBody().getContent()).doesNotContain("SECOND BANNER"); } + @Test + @DisplayName("Level 2 - Reset parameter clears the shared cache and restores the default banner") + void level2ShouldResetCacheWhenRequested() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + FILTERED_ATTACKER_BANNER, + false, + createLevelRequest(LEVEL_2_PATH, "poison")); + ResponseEntity> resetResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, true, createLevelRequest(LEVEL_2_PATH, null, null, null, true)); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, false, createLevelRequest(LEVEL_2_PATH, null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()) + .contains("ATUALIZACAO URGENTE FAKE"); + + assertValidResponse(resetResponse); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); + assertThat(resetResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain("ATUALIZACAO URGENTE FAKE"); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain("ATUALIZACAO URGENTE FAKE"); + } + @Test @DisplayName( "Level 3 - Forwarded host poisons the shared cache even after banner joins the key") @@ -173,11 +267,13 @@ void level3ShouldPoisonSharedCacheThroughUnkeyedForwardedHost() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + false, createLevelRequest( LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + false, createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); assertValidResponse(attackerResponse); @@ -205,10 +301,12 @@ void level3ShouldIncludeBannerInCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "status-board", + false, createLevelRequest(LEVEL_3_PATH, "status-board", ATTACKER_HOST, null)); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "landing-page", + false, createLevelRequest(LEVEL_3_PATH, "landing-page", SAFE_HOST, null)); assertThat( @@ -231,15 +329,65 @@ void level3ShouldIncludeBannerInCacheKey() { .doesNotContain("https://" + ATTACKER_HOST + "/assets/cache-poisoning-demo.js"); } + @Test + @DisplayName( + "Level 3 - Reset parameter clears the shared cache and removes the forwarded-host poisoning") + void level3ShouldResetCacheWhenRequested() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + LEVEL_3_SHARED_BANNER, + false, + createLevelRequest( + LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); + ResponseEntity> resetResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + null, true, createLevelRequest(LEVEL_3_PATH, null, null, null, true)); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel3( + LEVEL_3_SHARED_BANNER, + false, + createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_HOST); + + assertValidResponse(resetResponse); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); + assertThat(resetResponse.getBody().getContent()) + .contains(CachePoisoningVulnerability.DEFAULT_UNTRUSTED_HOST) + .doesNotContain(ATTACKER_HOST); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains(CachePoisoningVulnerability.DEFAULT_UNTRUSTED_HOST) + .doesNotContain(ATTACKER_HOST); + } + @Test @DisplayName("Level 4 - Public caching leaks personalized cookie content to another visitor") void level4ShouldLeakPersonalizedContentAcrossUsers() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(attackerResponse); @@ -262,6 +410,51 @@ void level4ShouldLeakPersonalizedContentAcrossUsers() { assertThat(victimResponse.getBody().getContent()).doesNotContain("guest"); } + @Test + @DisplayName( + "Level 4 - Reset parameter clears the shared cache and removes the personalized response") + void level4ShouldResetCacheWhenRequested() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, + createLevelRequest( + LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + ResponseEntity> resetResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + true, createLevelRequest(LEVEL_4_PATH, null, null, null, true)); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_USER); + + assertValidResponse(resetResponse); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); + assertThat(resetResponse.getBody().getContent()) + .contains("guest") + .doesNotContain(ATTACKER_USER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains("guest") + .doesNotContain(ATTACKER_USER); + } + @Test @DisplayName( "Level 5 - Secure cache policy prevents shared-cache leakage and ignores forwarding headers") @@ -269,6 +462,7 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { ResponseEntity> personalizedResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( "OWNED", + false, createLevelRequest( LEVEL_5_PATH, "OWNED", @@ -276,7 +470,7 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { demoUserCookie(ATTACKER_USER))); ResponseEntity> guestResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( - null, createLevelRequest(LEVEL_5_PATH, null, null, null)); + null, false, createLevelRequest(LEVEL_5_PATH, null, null, null)); assertValidResponse(personalizedResponse); assertThat(personalizedResponse.getHeaders().getCacheControl()) @@ -302,6 +496,58 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { assertThat(guestResponse.getBody().getContent()).doesNotContain(ATTACKER_USER); } + @Test + @DisplayName("Level 5 - Reset parameter returns a fresh safe response with an empty cache key") + void level5ShouldSupportResetRequest() { + ResponseEntity> personalizedResponse = + cachePoisoningVulnerability.getSecurePayloadLevel5( + "OWNED", + false, + createLevelRequest( + LEVEL_5_PATH, + "OWNED", + ATTACKER_HOST, + demoUserCookie(ATTACKER_USER))); + ResponseEntity> resetResponse = + cachePoisoningVulnerability.getSecurePayloadLevel5( + null, true, createLevelRequest(LEVEL_5_PATH, null, null, null, true)); + ResponseEntity> guestResponse = + cachePoisoningVulnerability.getSecurePayloadLevel5( + null, false, createLevelRequest(LEVEL_5_PATH, null, null, null)); + + assertValidResponse(personalizedResponse); + assertThat(personalizedResponse.getBody().getContent()).contains(ATTACKER_USER); + + assertValidResponse(resetResponse); + assertThat(resetResponse.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PRIVATE_NO_STORE); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat( + resetResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); + assertThat(resetResponse.getBody().getContent()) + .contains("guest") + .contains(CachePoisoningVulnerability.TRUSTED_ASSET_HOST) + .doesNotContain(ATTACKER_USER) + .doesNotContain(ATTACKER_HOST); + + assertValidResponse(guestResponse); + assertThat( + guestResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(guestResponse.getBody().getContent()) + .contains("guest") + .doesNotContain(ATTACKER_USER); + } + private void assertValidResponse( ResponseEntity> responseEntity) { assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -310,7 +556,11 @@ private void assertValidResponse( } private MockHttpServletRequest createLevel1Request(String queryString) { - return createLevelRequest(LEVEL_1_PATH, queryString); + return createLevel1Request(queryString, false); + } + + private MockHttpServletRequest createLevel1Request(String queryString, boolean resetCache) { + return createLevelRequest(LEVEL_1_PATH, queryString, null, null, resetCache); } private MockHttpServletRequest createLevelRequest(String path, String queryString) { @@ -319,11 +569,36 @@ private MockHttpServletRequest createLevelRequest(String path, String queryStrin private MockHttpServletRequest createLevelRequest( String path, String queryString, String forwardedHost, Cookie cookie) { + return createLevelRequest(path, queryString, forwardedHost, cookie, false); + } + + private MockHttpServletRequest createLevelRequest( + String path, + String queryString, + String forwardedHost, + Cookie cookie, + boolean resetCache) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setMethod("GET"); request.setRequestURI(path); + + StringBuilder requestQueryString = new StringBuilder(); if (queryString != null) { - request.setQueryString("banner=" + queryString); + requestQueryString + .append(CachePoisoningVulnerability.BANNER_QUERY_PARAMETER) + .append("=") + .append(queryString); + } + if (resetCache) { + if (requestQueryString.length() > 0) { + requestQueryString.append("&"); + } + requestQueryString + .append(CachePoisoningVulnerability.RESET_CACHE_QUERY_PARAMETER) + .append("=true"); + } + if (requestQueryString.length() > 0) { + request.setQueryString(requestQueryString.toString()); } if (forwardedHost != null) { request.addHeader(CachePoisoningVulnerability.FORWARDED_HOST_HEADER, forwardedHost); From 230afec270318437cf15915a40c6faaee64ddc0c Mon Sep 17 00:00:00 2001 From: luks-santos Date: Mon, 6 Apr 2026 21:13:17 -0300 Subject: [PATCH 07/20] refactor: move level-specific templates to common directory for Cache Poisoning --- .../CachePoisoningVulnerability.java | 10 +- .../{LEVEL_1 => Common}/CachePoisoning.css | 9 +- .../{LEVEL_5 => Common}/CachePoisoning.html | 65 +++--- .../CachePoisoning/Common/CachePoisoning.js | 210 ++++++++++++++++++ .../LEVEL_1/CachePoisoning.html | 58 ----- .../CachePoisoning/LEVEL_1/CachePoisoning.js | 142 ------------ .../CachePoisoning/LEVEL_2/CachePoisoning.css | 1 - .../LEVEL_2/CachePoisoning.html | 59 ----- .../CachePoisoning/LEVEL_2/CachePoisoning.js | 142 ------------ .../CachePoisoning/LEVEL_3/CachePoisoning.css | 1 - .../LEVEL_3/CachePoisoning.html | 79 ------- .../CachePoisoning/LEVEL_3/CachePoisoning.js | 154 ------------- .../CachePoisoning/LEVEL_4/CachePoisoning.css | 1 - .../LEVEL_4/CachePoisoning.html | 69 ------ .../CachePoisoning/LEVEL_4/CachePoisoning.js | 149 ------------- .../CachePoisoning/LEVEL_5/CachePoisoning.css | 1 - .../CachePoisoning/LEVEL_5/CachePoisoning.js | 172 -------------- 17 files changed, 254 insertions(+), 1068 deletions(-) rename src/main/resources/static/templates/CachePoisoning/{LEVEL_1 => Common}/CachePoisoning.css (97%) rename src/main/resources/static/templates/CachePoisoning/{LEVEL_5 => Common}/CachePoisoning.html (60%) create mode 100644 src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.css delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.css delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.css delete mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index e87fa2b5c..97b835ac6 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -60,7 +60,7 @@ public class CachePoisoningVulnerability { payload = "CACHE_POISONING_PAYLOAD_LEVEL_1") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_1, - htmlTemplate = "LEVEL_1/CachePoisoning") + htmlTemplate = "Common/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -87,7 +87,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_2") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_2, - htmlTemplate = "LEVEL_2/CachePoisoning") + htmlTemplate = "Common/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -114,7 +114,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_3") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_3, - htmlTemplate = "LEVEL_3/CachePoisoning") + htmlTemplate = "Common/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -144,7 +144,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_4") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_4, - htmlTemplate = "LEVEL_4/CachePoisoning") + htmlTemplate = "Common/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel4( @RequestParam( value = RESET_CACHE_QUERY_PARAMETER, @@ -170,7 +170,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_5") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_5, - htmlTemplate = "LEVEL_5/CachePoisoning", + htmlTemplate = "Common/CachePoisoning", variant = Variant.SECURE) public ResponseEntity> getSecurePayloadLevel5( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css similarity index 97% rename from src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css rename to src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css index 1cd15ff0b..219c72f42 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css @@ -54,6 +54,11 @@ border-bottom: 1px dotted black; } +/* Utilitário para esconder elementos */ +.hidden { + display: none !important; +} + .cache-poisoning-field { flex: 1 1 320px; min-width: 0; @@ -185,7 +190,9 @@ } #cacheStatus, -#cacheKey { +#cacheKey, +#cacheControl, +#varyHeader { display: block; padding: 6px 8px; border: 1px solid black; diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.html similarity index 60% rename from src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html rename to src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.html index 62102f0a9..842184298 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.html @@ -1,60 +1,60 @@ -
    -

    Cache Poisoning

    -

    - This secure level ignores untrusted forwarding headers and uses a private, non-stored - response for personalized content. Try sending the same malicious inputs and compare the - diagnostics with the previous levels. -

    +
    +

    Cache Poisoning

    +

    Controls
    -
    + + -
    + + + -
    + + + @@ -71,11 +71,11 @@
    Cache Signals
    Cache key -
    -
    + -
    + @@ -84,10 +84,7 @@
    Cache Signals
    Observed Response
    -
    - Send a personalized request first, then clear the cookie with a guest request. The - response should stay fresh and should not reuse attacker-controlled forwarding data. -
    +
    diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js new file mode 100644 index 000000000..07a9b671c --- /dev/null +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js @@ -0,0 +1,210 @@ +const CONFIGS = { + LEVEL_1: { + title: "Cache Poisoning - Level 1", + subtitle: "A shared cache stores responses by route only. Use the controls below and rely on the app help panel for exploitation guidance.", + banner: { show: true, label: "Attacker banner", placeholder: "FAKE URGENT UPDATE" }, + forwardedHost: { show: false }, + demoUser: { show: false }, + poisonBtn: "inline", + signals: ["status", "key"], + responseInitial: "Use the controls above to poison the cache and verify whether the victim receives the attacker-controlled banner.", + victimBtnText: "Send victim request" + }, + LEVEL_2: { + title: "Cache Poisoning - Level 2", + subtitle: "This level blocks only obvious script payloads. Try a harmless-looking HTML banner or a misleading status message and observe that the shared cache still replays it to the next visitor.", + banner: { show: true, label: "Filtered banner", placeholder: "URGENT STATUS PAGE UPDATE" }, + forwardedHost: { show: false }, + demoUser: { show: false }, + poisonBtn: "inline", + signals: ["status", "key"], + responseInitial: "Poison the cache with a filtered banner, then send the victim request and confirm the same response is replayed from the shared cache.", + victimBtnText: "Send victim request" + }, + LEVEL_3: { + title: "Cache Poisoning - Level 3", + subtitle: "This level fixes the banner cache key, but still trusts X-Forwarded-Host when building absolute asset URLs. Use the same banner value for both requests and change only the attacker header.", + banner: { show: true, label: "Shared banner key", placeholder: "shared-status-page" }, + forwardedHost: { show: true, label: "Attacker forwarded host", placeholder: "attacker.example" }, + demoUser: { show: false }, + poisonBtn: "inline", + signals: ["status", "key", "cacheControl", "vary"], + responseInitial: "Send the attacker request with a forwarded host, then repeat the victim request with the same banner key and no forwarded header.", + victimBtnText: "Send victim request" + }, + LEVEL_4: { + title: "Cache Poisoning - Level 4", + subtitle: "This level stops trusting X-Forwarded-Host, but still caches personalized content publicly. The attacker request sets demo_user; the victim request clears that cookie before loading the same route.", + banner: { show: false }, + forwardedHost: { show: false }, + demoUser: { show: true, label: "Attacker demo_user cookie", placeholder: "admin" }, + poisonBtn: "demo", + signals: ["status", "key", "cacheControl", "vary"], + responseInitial: "Personalize the attacker request with demo_user, then clear the cookie with the victim request and observe the reused shared-cache response.", + victimBtnText: "Send victim request" + }, + LEVEL_5: { + title: "Cache Poisoning - Level 5 (Secure)", + subtitle: "This secure level ignores untrusted forwarding headers and uses a private, non-stored response for personalized content. Try sending the same malicious inputs and compare the diagnostics with the previous levels.", + banner: { show: true, label: "Optional banner preview", placeholder: "OWNED" }, + forwardedHost: { show: true, label: "Optional forwarded host", placeholder: "attacker.example" }, + demoUser: { show: true, label: "Personalized demo_user", placeholder: "admin" }, + poisonBtn: "demo", + signals: ["status", "key", "cacheControl", "vary"], + responseInitial: "Send a personalized request first, then clear the cookie with a guest request. The response should stay fresh and should not reuse attacker-controlled forwarding data.", + victimBtnText: "Send guest request" + } +}; + +function getInputValue(id) { + const el = document.getElementById(id); + return el ? el.value.trim() : ""; +} + +function clearInputs() { + ["bannerInput", "forwardedHostInput", "demoUserInput"].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = ""; + }); +} + +function getNoBrowserCacheHeaders(headers = {}) { + return { ...headers, "Cache-Control": "no-cache" }; +} + +function getRequestUrl(includeInputs, resetCache = false) { + let url = getUrlForVulnerabilityLevel(); + let queryParams = new URLSearchParams(); + + if (resetCache) { + queryParams.set("resetCache", "true"); + } else if (includeInputs) { + const banner = getInputValue("bannerInput"); + if (banner) queryParams.set("banner", banner); + } + + let queryString = queryParams.toString(); + return queryString ? url + "?" + queryString : url; +} + +function getCustomHeaders() { + const headers = getNoBrowserCacheHeaders(); + const forwardedHost = getInputValue("forwardedHostInput"); + if (forwardedHost) { + headers["X-Forwarded-Host"] = forwardedHost; + } + return headers; +} + +function setDemoUserCookie(value) { + if (value) { + document.cookie = `demo_user=${value}; path=/; SameSite=Lax`; + } else { + document.cookie = "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + } +} + +function updateDiagnostics(request) { + const cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; + const cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; + const cacheControl = request.getResponseHeader("Cache-Control") || "-"; + const vary = request.getResponseHeader("Vary") || "-"; + + updateCacheStatusIndicator(cacheStatus); + document.getElementById("cacheKey").textContent = cacheKey; + if (document.getElementById("cacheControl")) document.getElementById("cacheControl").textContent = cacheControl; + if (document.getElementById("varyHeader")) document.getElementById("varyHeader").textContent = vary; + + updateResetCacheButton(cacheStatus, cacheKey); +} + +function updateCacheStatusIndicator(cacheStatus) { + const el = document.getElementById("cacheStatus"); + const status = String(cacheStatus || "-").trim().toUpperCase(); + + el.textContent = cacheStatus; + el.classList.remove("cache-status-hit", "cache-status-miss", "cache-status-neutral"); + + if (status === "HIT") el.classList.add("cache-status-hit"); + else if (status === "MISS") el.classList.add("cache-status-miss"); + else el.classList.add("cache-status-neutral"); +} + +function updateResetCacheButton(cacheStatus, cacheKey) { + const btn = document.getElementById("resetCacheBtn"); + const hasCache = cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; + btn.disabled = !hasCache; +} + +function fetchDataCallback(data, request) { + updateDiagnostics(request); + document.getElementById("cachePoisoningResponse").innerHTML = data.content; + // We don't clear inputs here to allow user to see what they sent, + // but some levels might prefer clearing. Let's keep it for now. +} + +function initLevel() { + const level = vulnerabilityLevelSelected || "LEVEL_1"; + const config = CONFIGS[level]; + + // Set texts + document.getElementById("cpTitle").innerHTML = config.title; + document.getElementById("cpSubtitle").innerHTML = config.subtitle; + document.getElementById("cachePoisoningResponse").innerHTML = config.responseInitial; + document.getElementById("victimRequestBtn").textContent = config.victimBtnText; + + // Configure Inputs + if (config.banner.show) { + document.getElementById("bannerInputRow").classList.remove("hidden"); + document.getElementById("bannerLabel").textContent = config.banner.label; + document.getElementById("bannerInput").placeholder = config.banner.placeholder; + } + + if (config.forwardedHost.show) { + document.getElementById("forwardedHostInputRow").classList.remove("hidden"); + document.getElementById("forwardedHostLabel").textContent = config.forwardedHost.label; + document.getElementById("forwardedHostInput").placeholder = config.forwardedHost.placeholder; + } + + if (config.demoUser.show) { + document.getElementById("demoUserInputRow").classList.remove("hidden"); + document.getElementById("demoUserLabel").textContent = config.demoUser.label; + document.getElementById("demoUserInput").placeholder = config.demoUser.placeholder; + } + + // Configure Poison Button Position + const poisonBtnId = config.poisonBtn === "inline" ? "poisonCacheBtnInline" : + (config.poisonBtn === "demo" ? "poisonCacheBtnDemo" : "poisonCacheBtnBottom"); + const poisonBtnContainerId = config.poisonBtn === "inline" ? "poisonCacheBtnContainerInline" : + (config.poisonBtn === "demo" ? "poisonCacheBtnContainerDemo" : ""); + + document.getElementById(poisonBtnContainerId).classList.remove("hidden"); + + const poisonHandler = function() { + const demoUser = getInputValue("demoUserInput"); + if (config.demoUser.show && demoUser) { + setDemoUserCookie(demoUser); + } + doGetAjaxCall(fetchDataCallback, getRequestUrl(true), true, getCustomHeaders()); + }; + + document.getElementById(poisonBtnId).addEventListener("click", poisonHandler); + + // Other Events + document.getElementById("resetCacheBtn").addEventListener("click", function() { + doGetAjaxCall(fetchDataCallback, getRequestUrl(false, true), true, getNoBrowserCacheHeaders()); + }); + + document.getElementById("victimRequestBtn").addEventListener("click", function() { + if (config.demoUser.show) { + setDemoUserCookie(null); // Clear cookie for victim request in demo user levels + } + doGetAjaxCall(fetchDataCallback, getRequestUrl(false), true, getNoBrowserCacheHeaders()); + }); + + // Configure Signals + if (config.signals.includes("cacheControl")) document.getElementById("cacheControlCard").classList.remove("hidden"); + if (config.signals.includes("vary")) document.getElementById("varyHeaderCard").classList.remove("hidden"); +} + +initLevel(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html deleted file mode 100644 index 2cbdc9579..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html +++ /dev/null @@ -1,58 +0,0 @@ -
    -

    Cache Poisoning

    -

    - A shared cache stores responses by route only. Use the controls below and rely on the app - help panel for exploitation guidance. -

    - -
    -
    -
    Controls
    -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    Cache Signals
    -
    -
    - Cache status - - -
    -
    - Cache key - - -
    -
    -
    - -
    -
    Observed Response
    -
    - Use the controls above to poison the cache and verify whether the victim receives - the attacker-controlled banner. -
    -
    -
    -
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js deleted file mode 100644 index 7da4e0d45..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js +++ /dev/null @@ -1,142 +0,0 @@ -function getBannerValue() { - return document.getElementById("bannerInput").value.trim(); -} - -function clearBannerValue() { - document.getElementById("bannerInput").value = ""; -} - -function requireBannerValue() { - let banner = getBannerValue(); - if (banner) { - return banner; - } - - alert("Banner is required"); - document.getElementById("bannerInput").focus(); - return null; -} - -function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-cache", - }; -} - -function getLevel1Url(includeBanner, resetCache = false) { - let url = getUrlForVulnerabilityLevel(); - let queryParams = new URLSearchParams(); - - if (includeBanner) { - let banner = getBannerValue(); - if (banner) { - queryParams.set("banner", banner); - } - } - - if (resetCache) { - queryParams.set("resetCache", "true"); - } - - let queryString = queryParams.toString(); - return queryString ? url + "?" + queryString : url; -} - -function updateDiagnostics(request) { - let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; - let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; - - updateCacheStatusIndicator(cacheStatus); - document.getElementById("cacheKey").textContent = cacheKey; - updateResetCacheButton(cacheStatus, cacheKey); -} - -function updateResponseArea(content) { - document.getElementById("cachePoisoningResponse").innerHTML = content; -} - -function updateCacheStatusIndicator(cacheStatus) { - let cacheStatusElement = document.getElementById("cacheStatus"); - let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); - - cacheStatusElement.textContent = cacheStatus; - cacheStatusElement.classList.remove( - "cache-status-hit", - "cache-status-miss", - "cache-status-neutral" - ); - - if (normalizedCacheStatus === "HIT") { - cacheStatusElement.classList.add("cache-status-hit"); - return; - } - - if (normalizedCacheStatus === "MISS") { - cacheStatusElement.classList.add("cache-status-miss"); - return; - } - - cacheStatusElement.classList.add("cache-status-neutral"); -} - -function updateResetCacheButton(cacheStatus, cacheKey) { - let resetCacheButton = document.getElementById("resetCacheBtn"); - let hasCachedRequest = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - - resetCacheButton.disabled = !hasCachedRequest; - resetCacheButton.title = hasCachedRequest - ? "Cached request available" - : "No cached request yet"; -} - -function fetchDataCallback(data, request) { - updateDiagnostics(request); - updateResponseArea(data.content); - clearBannerValue(); -} - -function addEvents() { - document - .getElementById("resetCacheBtn") - .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getLevel1Url(false, true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("poisonCacheBtn") - .addEventListener("click", function () { - let banner = requireBannerValue(); - if (!banner) { - return; - } - - doGetAjaxCall( - fetchDataCallback, - getLevel1Url(true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("victimRequestBtn") - .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getLevel1Url(false), - true, - getNoBrowserCacheHeaders() - ); - }); -} - -updateCacheStatusIndicator("-"); -updateResetCacheButton("-", "-"); -addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css deleted file mode 100644 index 6fb199a3b..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css +++ /dev/null @@ -1 +0,0 @@ -@import "../LEVEL_1/CachePoisoning.css"; diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html deleted file mode 100644 index e9301f57a..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html +++ /dev/null @@ -1,59 +0,0 @@ -
    -

    Cache Poisoning

    -

    - This level blocks only obvious script payloads. Try a harmless-looking HTML banner or a - misleading status message and observe that the shared cache still replays it to the next - visitor. -

    - -
    -
    -
    Controls
    -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    Cache Signals
    -
    -
    - Cache status - - -
    -
    - Cache key - - -
    -
    -
    - -
    -
    Observed Response
    -
    - Poison the cache with a filtered banner, then send the victim request and confirm - the same response is replayed from the shared cache. -
    -
    -
    -
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js deleted file mode 100644 index 332046b92..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js +++ /dev/null @@ -1,142 +0,0 @@ -function getBannerValue() { - return document.getElementById("bannerInput").value.trim(); -} - -function clearBannerValue() { - document.getElementById("bannerInput").value = ""; -} - -function requireBannerValue() { - let banner = getBannerValue(); - if (banner) { - return banner; - } - - alert("Banner is required"); - document.getElementById("bannerInput").focus(); - return null; -} - -function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-cache", - }; -} - -function getCachePoisoningUrl(includeBanner, resetCache = false) { - let url = getUrlForVulnerabilityLevel(); - let queryParams = new URLSearchParams(); - - if (includeBanner) { - let banner = getBannerValue(); - if (banner) { - queryParams.set("banner", banner); - } - } - - if (resetCache) { - queryParams.set("resetCache", "true"); - } - - let queryString = queryParams.toString(); - return queryString ? url + "?" + queryString : url; -} - -function updateDiagnostics(request) { - let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; - let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; - - updateCacheStatusIndicator(cacheStatus); - document.getElementById("cacheKey").textContent = cacheKey; - updateResetCacheButton(cacheStatus, cacheKey); -} - -function updateResponseArea(content) { - document.getElementById("cachePoisoningResponse").innerHTML = content; -} - -function updateCacheStatusIndicator(cacheStatus) { - let cacheStatusElement = document.getElementById("cacheStatus"); - let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); - - cacheStatusElement.textContent = cacheStatus; - cacheStatusElement.classList.remove( - "cache-status-hit", - "cache-status-miss", - "cache-status-neutral" - ); - - if (normalizedCacheStatus === "HIT") { - cacheStatusElement.classList.add("cache-status-hit"); - return; - } - - if (normalizedCacheStatus === "MISS") { - cacheStatusElement.classList.add("cache-status-miss"); - return; - } - - cacheStatusElement.classList.add("cache-status-neutral"); -} - -function updateResetCacheButton(cacheStatus, cacheKey) { - let resetCacheButton = document.getElementById("resetCacheBtn"); - let hasCachedRequest = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - - resetCacheButton.disabled = !hasCachedRequest; - resetCacheButton.title = hasCachedRequest - ? "Cached request available" - : "No cached request yet"; -} - -function fetchDataCallback(data, request) { - updateDiagnostics(request); - updateResponseArea(data.content); - clearBannerValue(); -} - -function addEvents() { - document - .getElementById("resetCacheBtn") - .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getCachePoisoningUrl(false, true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("poisonCacheBtn") - .addEventListener("click", function () { - let banner = requireBannerValue(); - if (!banner) { - return; - } - - doGetAjaxCall( - fetchDataCallback, - getCachePoisoningUrl(true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("victimRequestBtn") - .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getCachePoisoningUrl(false), - true, - getNoBrowserCacheHeaders() - ); - }); -} - -updateCacheStatusIndicator("-"); -updateResetCacheButton("-", "-"); -addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.css deleted file mode 100644 index 6fb199a3b..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.css +++ /dev/null @@ -1 +0,0 @@ -@import "../LEVEL_1/CachePoisoning.css"; diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html deleted file mode 100644 index 51c3bca70..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html +++ /dev/null @@ -1,79 +0,0 @@ -
    -

    Cache Poisoning

    -

    - This level fixes the banner cache key, but still trusts X-Forwarded-Host when - building absolute asset URLs. Use the same banner value for both requests and change only - the attacker header. -

    - -
    -
    -
    Controls
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    Cache Signals
    -
    -
    - Cache status - - -
    -
    - Cache key - - -
    -
    - Cache-Control - - -
    -
    - Vary - - -
    -
    -
    - -
    -
    Observed Response
    -
    - Send the attacker request with a forwarded host, then repeat the victim request with - the same banner key and no forwarded header. -
    -
    -
    -
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js deleted file mode 100644 index d16fcc392..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js +++ /dev/null @@ -1,154 +0,0 @@ -function getBannerValue() { - return document.getElementById("bannerInput").value.trim(); -} - -function getForwardedHostValue() { - return document.getElementById("forwardedHostInput").value.trim(); -} - -function clearInputs() { - document.getElementById("bannerInput").value = ""; - document.getElementById("forwardedHostInput").value = ""; -} - -function requireValue(inputId, label) { - let value = document.getElementById(inputId).value.trim(); - if (value) { - return value; - } - - alert(label + " is required"); - document.getElementById(inputId).focus(); - return null; -} - -function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-cache", - }; -} - -function buildLevel3Url(includeBanner, resetCache = false) { - let url = getUrlForVulnerabilityLevel(); - let queryParams = new URLSearchParams(); - - if (includeBanner) { - queryParams.set("banner", getBannerValue()); - } - - if (resetCache) { - queryParams.set("resetCache", "true"); - } - - let queryString = queryParams.toString(); - return queryString ? url + "?" + queryString : url; -} - -function updateDiagnostics(request) { - let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; - let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; - - updateCacheStatusIndicator(cacheStatus); - document.getElementById("cacheKey").textContent = cacheKey; - document.getElementById("cacheControl").textContent = - request.getResponseHeader("Cache-Control") || "-"; - document.getElementById("varyHeader").textContent = - request.getResponseHeader("Vary") || "-"; - updateResetCacheButton(cacheStatus, cacheKey); -} - -function updateResponseArea(content) { - document.getElementById("cachePoisoningResponse").innerHTML = content; -} - -function updateCacheStatusIndicator(cacheStatus) { - let cacheStatusElement = document.getElementById("cacheStatus"); - let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); - - cacheStatusElement.textContent = cacheStatus; - cacheStatusElement.classList.remove( - "cache-status-hit", - "cache-status-miss", - "cache-status-neutral" - ); - - if (normalizedCacheStatus === "HIT") { - cacheStatusElement.classList.add("cache-status-hit"); - return; - } - - if (normalizedCacheStatus === "MISS") { - cacheStatusElement.classList.add("cache-status-miss"); - return; - } - - cacheStatusElement.classList.add("cache-status-neutral"); -} - -function updateResetCacheButton(cacheStatus, cacheKey) { - let resetCacheButton = document.getElementById("resetCacheBtn"); - let hasCachedRequest = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - - resetCacheButton.disabled = !hasCachedRequest; - resetCacheButton.title = hasCachedRequest - ? "Cached request available" - : "No cached request yet"; -} - -function fetchDataCallback(data, request) { - updateDiagnostics(request); - updateResponseArea(data.content); - clearInputs(); -} - -function addEvents() { - document - .getElementById("resetCacheBtn") - .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - buildLevel3Url(false, true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("poisonCacheBtn") - .addEventListener("click", function () { - let banner = requireValue("bannerInput", "Banner"); - let forwardedHost = requireValue("forwardedHostInput", "Forwarded host"); - if (!banner || !forwardedHost) { - return; - } - - doGetAjaxCall( - fetchDataCallback, - buildLevel3Url(true), - true, - getNoBrowserCacheHeaders({ "X-Forwarded-Host": forwardedHost }) - ); - }); - - document - .getElementById("victimRequestBtn") - .addEventListener("click", function () { - let banner = requireValue("bannerInput", "Banner"); - if (!banner) { - return; - } - - doGetAjaxCall( - fetchDataCallback, - buildLevel3Url(true), - true, - getNoBrowserCacheHeaders() - ); - }); -} - -updateCacheStatusIndicator("-"); -updateResetCacheButton("-", "-"); -addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.css deleted file mode 100644 index 6fb199a3b..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.css +++ /dev/null @@ -1 +0,0 @@ -@import "../LEVEL_1/CachePoisoning.css"; diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html deleted file mode 100644 index 1657c7349..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html +++ /dev/null @@ -1,69 +0,0 @@ -
    -

    Cache Poisoning

    -

    - This level stops trusting X-Forwarded-Host, but still caches personalized - content publicly. The attacker request sets demo_user; the victim request - clears that cookie before loading the same route. -

    - -
    -
    -
    Controls
    -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    Cache Signals
    -
    -
    - Cache status - - -
    -
    - Cache key - - -
    -
    - Cache-Control - - -
    -
    - Vary - - -
    -
    -
    - -
    -
    Observed Response
    -
    - Personalize the attacker request with demo_user, then clear the cookie - with the victim request and observe the reused shared-cache response. -
    -
    -
    -
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js deleted file mode 100644 index 2f3ec8434..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js +++ /dev/null @@ -1,149 +0,0 @@ -function getDemoUserValue() { - return document.getElementById("demoUserInput").value.trim(); -} - -function clearDemoUserValue() { - document.getElementById("demoUserInput").value = ""; -} - -function requireDemoUserValue() { - let demoUser = getDemoUserValue(); - if (demoUser) { - return demoUser; - } - - alert("Demo user is required"); - document.getElementById("demoUserInput").focus(); - return null; -} - -function setDemoUserCookie(demoUser) { - document.cookie = - "demo_user=" + encodeURIComponent(demoUser) + "; path=/; SameSite=Lax"; -} - -function clearDemoUserCookie() { - document.cookie = - "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"; -} - -function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-cache", - }; -} - -function getLevel4Url(resetCache = false) { - let url = getUrlForVulnerabilityLevel(); - if (!resetCache) { - return url; - } - - return url + "?resetCache=true"; -} - -function updateDiagnostics(request) { - let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; - let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; - - updateCacheStatusIndicator(cacheStatus); - document.getElementById("cacheKey").textContent = cacheKey; - document.getElementById("cacheControl").textContent = - request.getResponseHeader("Cache-Control") || "-"; - document.getElementById("varyHeader").textContent = - request.getResponseHeader("Vary") || "-"; - updateResetCacheButton(cacheStatus, cacheKey); -} - -function updateResponseArea(content) { - document.getElementById("cachePoisoningResponse").innerHTML = content; -} - -function updateCacheStatusIndicator(cacheStatus) { - let cacheStatusElement = document.getElementById("cacheStatus"); - let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); - - cacheStatusElement.textContent = cacheStatus; - cacheStatusElement.classList.remove( - "cache-status-hit", - "cache-status-miss", - "cache-status-neutral" - ); - - if (normalizedCacheStatus === "HIT") { - cacheStatusElement.classList.add("cache-status-hit"); - return; - } - - if (normalizedCacheStatus === "MISS") { - cacheStatusElement.classList.add("cache-status-miss"); - return; - } - - cacheStatusElement.classList.add("cache-status-neutral"); -} - -function updateResetCacheButton(cacheStatus, cacheKey) { - let resetCacheButton = document.getElementById("resetCacheBtn"); - let hasCachedRequest = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - - resetCacheButton.disabled = !hasCachedRequest; - resetCacheButton.title = hasCachedRequest - ? "Cached request available" - : "No cached request yet"; -} - -function fetchDataCallback(data, request) { - updateDiagnostics(request); - updateResponseArea(data.content); - clearDemoUserValue(); -} - -function addEvents() { - document - .getElementById("resetCacheBtn") - .addEventListener("click", function () { - clearDemoUserCookie(); - doGetAjaxCall( - fetchDataCallback, - getLevel4Url(true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("poisonCacheBtn") - .addEventListener("click", function () { - let demoUser = requireDemoUserValue(); - if (!demoUser) { - return; - } - - setDemoUserCookie(demoUser); - doGetAjaxCall( - fetchDataCallback, - getLevel4Url(), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("victimRequestBtn") - .addEventListener("click", function () { - clearDemoUserCookie(); - doGetAjaxCall( - fetchDataCallback, - getLevel4Url(), - true, - getNoBrowserCacheHeaders() - ); - }); -} - -updateCacheStatusIndicator("-"); -updateResetCacheButton("-", "-"); -addEvents(); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.css deleted file mode 100644 index 6fb199a3b..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.css +++ /dev/null @@ -1 +0,0 @@ -@import "../LEVEL_1/CachePoisoning.css"; diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js deleted file mode 100644 index a0a6b5f7d..000000000 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js +++ /dev/null @@ -1,172 +0,0 @@ -function getDemoUserValue() { - return document.getElementById("demoUserInput").value.trim(); -} - -function getBannerValue() { - return document.getElementById("bannerInput").value.trim(); -} - -function getForwardedHostValue() { - return document.getElementById("forwardedHostInput").value.trim(); -} - -function clearInputs() { - document.getElementById("demoUserInput").value = ""; - document.getElementById("bannerInput").value = ""; - document.getElementById("forwardedHostInput").value = ""; -} - -function requireDemoUserValue() { - let demoUser = getDemoUserValue(); - if (demoUser) { - return demoUser; - } - - alert("Demo user is required"); - document.getElementById("demoUserInput").focus(); - return null; -} - -function setDemoUserCookie(demoUser) { - document.cookie = - "demo_user=" + encodeURIComponent(demoUser) + "; path=/; SameSite=Lax"; -} - -function clearDemoUserCookie() { - document.cookie = - "demo_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"; -} - -function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-cache", - }; -} - -function buildLevel5Url(includeBanner = true, resetCache = false) { - let url = getUrlForVulnerabilityLevel(); - let queryParams = new URLSearchParams(); - - if (includeBanner) { - let banner = getBannerValue(); - if (banner) { - queryParams.set("banner", banner); - } - } - - if (resetCache) { - queryParams.set("resetCache", "true"); - } - - let queryString = queryParams.toString(); - return queryString ? url + "?" + queryString : url; -} - -function updateDiagnostics(request) { - let cacheStatus = request.getResponseHeader("X-Cache-Status") || "-"; - let cacheKey = request.getResponseHeader("X-Cache-Key") || "-"; - - updateCacheStatusIndicator(cacheStatus); - document.getElementById("cacheKey").textContent = cacheKey; - document.getElementById("cacheControl").textContent = - request.getResponseHeader("Cache-Control") || "-"; - document.getElementById("varyHeader").textContent = - request.getResponseHeader("Vary") || "-"; - updateResetCacheButton(cacheStatus, cacheKey); -} - -function updateResponseArea(content) { - document.getElementById("cachePoisoningResponse").innerHTML = content; -} - -function updateCacheStatusIndicator(cacheStatus) { - let cacheStatusElement = document.getElementById("cacheStatus"); - let normalizedCacheStatus = String(cacheStatus || "-").trim().toUpperCase(); - - cacheStatusElement.textContent = cacheStatus; - cacheStatusElement.classList.remove( - "cache-status-hit", - "cache-status-miss", - "cache-status-neutral" - ); - - if (normalizedCacheStatus === "HIT") { - cacheStatusElement.classList.add("cache-status-hit"); - return; - } - - if (normalizedCacheStatus === "MISS") { - cacheStatusElement.classList.add("cache-status-miss"); - return; - } - - cacheStatusElement.classList.add("cache-status-neutral"); -} - -function updateResetCacheButton(cacheStatus, cacheKey) { - let resetCacheButton = document.getElementById("resetCacheBtn"); - let hasCachedRequest = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - - resetCacheButton.disabled = !hasCachedRequest; - resetCacheButton.title = hasCachedRequest - ? "Cached request available" - : "No cached request yet"; -} - -function fetchDataCallback(data, request) { - updateDiagnostics(request); - updateResponseArea(data.content); - clearInputs(); -} - -function addEvents() { - document - .getElementById("resetCacheBtn") - .addEventListener("click", function () { - clearDemoUserCookie(); - doGetAjaxCall( - fetchDataCallback, - buildLevel5Url(false, true), - true, - getNoBrowserCacheHeaders() - ); - }); - - document - .getElementById("poisonCacheBtn") - .addEventListener("click", function () { - let demoUser = requireDemoUserValue(); - let forwardedHost = getForwardedHostValue(); - if (!demoUser) { - return; - } - - setDemoUserCookie(demoUser); - doGetAjaxCall( - fetchDataCallback, - buildLevel5Url(), - true, - getNoBrowserCacheHeaders( - forwardedHost ? { "X-Forwarded-Host": forwardedHost } : {} - ) - ); - }); - - document - .getElementById("victimRequestBtn") - .addEventListener("click", function () { - clearDemoUserCookie(); - doGetAjaxCall( - fetchDataCallback, - buildLevel5Url(), - true, - getNoBrowserCacheHeaders() - ); - }); -} - -updateCacheStatusIndicator("-"); -updateResetCacheButton("-", "-"); -addEvents(); From 179777a18705632d072461d50f63d216da351526 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Mon, 6 Apr 2026 21:16:54 -0300 Subject: [PATCH 08/20] feat: make Cache Poisoning Level 1 vulnerable to XSS --- .../cachePoisoning/CachePoisoningVulnerability.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 97b835ac6..fdf7a6045 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -196,12 +196,11 @@ public ResponseEntity> getSecurePayload } private String buildLevel1Response(String banner) { - String safeBanner = - StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER)); + String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); return "
    " + "

    Shared cache response

    " + "

    Banner: " - + safeBanner + + unsafeBanner + "

    " + "

    This level intentionally caches the page by route only.

    " + "

    Poison the cache once with a crafted banner and the next visitor reuses it.

    " From daf19fde32ed87533ab16afb99edc56a41a594d6 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Sat, 11 Apr 2026 19:09:28 -0300 Subject: [PATCH 09/20] docs: add links for Web Cache Poisoning videos --- src/main/resources/i18n/messages.properties | 4 +++- src/main/resources/i18n/messages_en_US.properties | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index f2ec28bd6..f8bd8a853 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -377,7 +377,9 @@ Important Links:
    \
    1. OWASP Cache Poisoning \
    2. PortSwigger Web Cache Poisoning \
    3. RFC 9111 - HTTP Caching \ -
    +
  • Youtube Lab: Web cache poisoning \ +
  • Youtube: Novel Web Cache Poisoning Techniques \ + CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=The application now includes the banner in the cache key, but still trusts X-Forwarded-Host when building absolute URLs. Because the shared cache ignores that header, attackers can still poison the cached asset host. diff --git a/src/main/resources/i18n/messages_en_US.properties b/src/main/resources/i18n/messages_en_US.properties index bb113f529..48d7bbd25 100755 --- a/src/main/resources/i18n/messages_en_US.properties +++ b/src/main/resources/i18n/messages_en_US.properties @@ -297,6 +297,8 @@ Important Links:
    \
    1. OWASP Cache Poisoning \
    2. PortSwigger Web Cache Poisoning \
    3. RFC 9111 - HTTP Caching \ +
    4. Youtube Lab: Web cache poisoning \ +
    5. Youtube: Novel Web Cache Poisoning Techniques \
    CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. From 2673d64072f36a41202feda825a2d56e6e206f5a Mon Sep 17 00:00:00 2001 From: luks-santos Date: Sun, 12 Apr 2026 18:42:38 -0300 Subject: [PATCH 10/20] refactor: enhance cache poisoning theory and fix level 3 simulation --- .../CachePoisoningVulnerability.java | 59 ++++++++----------- src/main/resources/i18n/messages.properties | 16 ++--- .../resources/i18n/messages_en_US.properties | 13 ++-- .../CachePoisoning/Common/CachePoisoning.js | 10 +++- 4 files changed, 44 insertions(+), 54 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index fdf7a6045..26e56e3ed 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -198,25 +198,24 @@ public ResponseEntity> getSecurePayload private String buildLevel1Response(String banner) { String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); return "
    " - + "

    Shared cache response

    " - + "

    Banner: " + + "

    Shared Cache Response

    " + + "

    Current Banner: " + unsafeBanner + "

    " - + "

    This level intentionally caches the page by route only.

    " - + "

    Poison the cache once with a crafted banner and the next visitor reuses it.

    " + + "

    The application reflects the banner parameter, but the cache only uses the route as the key.

    " + + "

    Try poisoning the banner and see if it persists for other requests.

    " + "
    "; } private String buildLevel2Response(String banner) { String filteredBanner = applyNaiveBannerFilter(banner); return "
    " - + "

    Shared cache response

    " - + "

    Banner: " + + "

    Filtered Cache Response

    " + + "

    Current Banner: " + filteredBanner + "

    " - + "

    This level strips obvious <script> payloads but still caches by route" - + " only.

    " - + "

    Misleading text and harmless-looking HTML still poison the shared cache.

    " + + "

    Obvious <script> tags are stripped, but the cache key remains route-only.

    " + + "

    Can you still poison the cache with other HTML or misleading information?

    " + "
    "; } @@ -224,19 +223,17 @@ private String buildLevel3Response(String banner, HttpServletRequest request) { String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); String assetUrl = buildAssetUrl(resolveUntrustedForwardedHost(request)); return "
    " - + "

    Forwarded host poisoning

    " - + "

    Banner key: " + + "

    Dynamic Asset Loading

    " + + "

    Banner Key: " + safeBanner + "

    " - + "

    Boot asset URL: Active Asset URL: " + assetUrl + "

    " - + "

    This level fixes the query-param cache key, but still trusts" - + " X-Forwarded-Host when building absolute URLs.

    " - + "

    The shared cache ignores that header, so the next visitor can receive a" - + " poisoned asset host.

    " + + "

    The banner is now part of the cache key, but the application trusts the X-Forwarded-Host header for asset URLs.

    " + + "

    If the cache ignores this header, the asset location can be poisoned.

    " + "
    "; } @@ -245,17 +242,12 @@ private String buildLevel4Response(HttpServletRequest request) { StringEscapeUtils.escapeHtml4( StringUtils.defaultIfBlank(resolveDemoUser(request), DEFAULT_DEMO_USER)); return "
    " - + "

    Personalized response in a shared cache

    " - + "

    Viewer: " + + "

    Personalized Dashboard

    " + + "

    Logged in as: " + safeUser + "

    " - + "

    Dashboard message: Welcome back, " - + safeUser - + ". This response is personalized but still marked public.

    " - + "

    The cookie changes the content, yet the shared cache key still uses only" - + " the route.

    " - + "

    Another user can therefore receive someone else's cached dashboard" - + " greeting.

    " + + "

    This response is personalized based on your session cookie but is marked as public.

    " + + "

    Check if your personalized dashboard appears for other users due to shared cache reuse.

    " + "
    "; } @@ -266,22 +258,17 @@ private String buildLevel5Response(String banner, HttpServletRequest request) { StringUtils.defaultIfBlank(resolveDemoUser(request), DEFAULT_DEMO_USER)); String trustedAssetUrl = buildAssetUrl(TRUSTED_ASSET_HOST); return "
    " - + "

    Secure cache handling

    " - + "

    Viewer: " + + "

    Secure Implementation

    " + + "

    User: " + safeUser + "

    " - + "

    Trusted asset URL: " + + "

    Trusted Asset: " + trustedAssetUrl - + "

    " - + "

    Banner preview (escaped): " + + "

    " + + "

    Banner: " + safeBanner + "

    " - + "

    This level ignores untrusted forwarding headers and does not place" - + " personalized content in the shared cache.

    " - + "

    Responses are marked private and no-store, so each visitor gets a fresh" - + " response.

    " + + "

    This level uses private cache policies and ignores untrusted headers.

    " + "
    "; } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index f8bd8a853..197d1e5ba 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -371,17 +371,17 @@ LDAP_LEVEL_5_BLIND_INJECTION=Blind LDAP Injection leading to Authentication Bypa LDAP_LEVEL_6_SECURE=This level demonstrates a fully secure LDAP authentication implementation. \ User input is properly encoded, password verification is done using salted hashing, \ and no sensitive infformation is exposed in responses. LDAP Injection is not possible. # Cache Poisoning Vulnerability -CACHE_POISONING_VULNERABILITY=Web Cache Poisoning happens when an attacker makes a cache store an incorrect response and that response is then reused for other users. \ -This level uses a shared in-memory cache to show the core issue: the response changes with user input, but the cache key only uses the route. \ +CACHE_POISONING_VULNERABILITY=Web Cache Poisoning occurs when an attacker manipulates a shared web cache\u2014such as a CDN, Reverse Proxy, or Load Balancer\u2014to store a malicious or unintended response. These systems are designed to optimize performance by serving cached versions of frequently requested resources to reduce latency and backend load. The attack succeeds when there is a discrepancy between the Cache Key (the set of request attributes the cache uses to identify a resource) and the application's processing logic. By manipulating unkeyed inputs (headers, cookies, or parameters that are reflected in the response but not included in the cache key), an attacker can "poison" the cache for a specific resource. Once stored, this poisoned response is served to all subsequent users requesting that resource until the cache entry expires or is cleared.

    \ Important Links:
    \
    1. OWASP Cache Poisoning \
    2. PortSwigger Web Cache Poisoning \
    3. RFC 9111 - HTTP Caching \
    4. Youtube Lab: Web cache poisoning \
    5. Youtube: Novel Web Cache Poisoning Techniques \ -
    -CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. -CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. -CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=The application now includes the banner in the cache key, but still trusts X-Forwarded-Host when building absolute URLs. Because the shared cache ignores that header, attackers can still poison the cached asset host. -CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT=The application stops trusting forwarding headers, but still caches personalized cookie-driven content as public. Shared cache reuse can therefore leak one user's response to another visitor. -CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY=This secure level ignores untrusted forwarding headers and marks personalized responses as private and no-store, preventing shared-cache reuse between visitors. + +CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=This level demonstrates a cache key that only includes the request path (route). The application reflects a query parameter in the response but does not include it in the cache key. An attacker can poison the resource for all users by providing a malicious parameter in the first request. +CACHE_POISONING_LEVEL_2_NAIVE_FILTER=This level attempts to fix the issue by stripping script tags from the input, but the cache key still only uses the route. Attackers can still poison the cache with misleading content or other HTML payloads that bypass the naive filter. +CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=This level includes the query parameter in the cache key but still trusts the X-Forwarded-Host header to build absolute URLs for assets. Since the shared cache does not include this header in its key (Unkeyed Header), an attacker can poison the asset host for all subsequent visitors. +CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT=This level demonstrates the danger of caching personalized content (driven by cookies) as public. While the content changes per user, the cache key remains the same (route-based), leading to one user's private information being served to another visitor. +CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY=This secure level demonstrates proper cache isolation. It ignores untrusted forwarding headers and marks personalized or sensitive responses as private and no-store, preventing shared caches from storing and reusing them across different users. + diff --git a/src/main/resources/i18n/messages_en_US.properties b/src/main/resources/i18n/messages_en_US.properties index 48d7bbd25..7d7c2f686 100755 --- a/src/main/resources/i18n/messages_en_US.properties +++ b/src/main/resources/i18n/messages_en_US.properties @@ -291,8 +291,7 @@ SSRF_VULNERABILITY_URL_ONLY_IF_IN_THE_WHITELIST=Only Whitelisted URL is allowed. HEADER_INJECTION_VULNERABILITY=It tests how a JWT header can be manipulated to alter the signature verification. # Cache Poisoning Vulnerability -CACHE_POISONING_VULNERABILITY=Web Cache Poisoning happens when an attacker makes a cache store an incorrect response and that response is then reused for other users. \ -This level uses a shared in-memory cache to show the core issue: the response changes with user input, but the cache key only uses the route. \ +CACHE_POISONING_VULNERABILITY=Web Cache Poisoning occurs when an attacker manipulates a shared web cache\u2014such as a CDN, Reverse Proxy, or Load Balancer\u2014to store a malicious or unintended response. These systems are designed to optimize performance by serving cached versions of frequently requested resources to reduce latency and backend load. The attack succeeds when there is a discrepancy between the Cache Key (the set of request attributes the cache uses to identify a resource) and the application's processing logic. By manipulating unkeyed inputs (headers, cookies, or parameters that are reflected in the response but not included in the cache key), an attacker can "poison" the cache for a specific resource. Once stored, this poisoned response is served to all subsequent users requesting that resource until the cache entry expires or is cleared.

    \ Important Links:
    \
    1. OWASP Cache Poisoning \
    2. PortSwigger Web Cache Poisoning \ @@ -300,8 +299,8 @@ Important Links:
      \
    3. Youtube Lab: Web cache poisoning \
    4. Youtube: Novel Web Cache Poisoning Techniques \
    -CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=The response reflects the banner query parameter, but the shared cache key only uses the route. Poison the cache once and the next visitor reuses the poisoned banner. -CACHE_POISONING_LEVEL_2_NAIVE_FILTER=The application tries to fix the issue by stripping obvious script payloads, but the response still varies with attacker-controlled input while the shared cache key only uses the route. -CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=The application now includes the banner in the cache key, but still trusts X-Forwarded-Host when building absolute URLs. Because the shared cache ignores that header, attackers can still poison the cached asset host. -CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT=The application stops trusting forwarding headers, but still caches personalized cookie-driven content as public. Shared cache reuse can therefore leak one user's response to another visitor. -CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY=This secure level ignores untrusted forwarding headers and marks personalized responses as private and no-store, preventing shared-cache reuse between visitors. +CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=This level demonstrates a cache key that only includes the request path (route). The application reflects a query parameter in the response but does not include it in the cache key. An attacker can poison the resource for all users by providing a malicious parameter in the first request. +CACHE_POISONING_LEVEL_2_NAIVE_FILTER=This level attempts to fix the issue by stripping script tags from the input, but the cache key still only uses the route. Attackers can still poison the cache with misleading content or other HTML payloads that bypass the naive filter. +CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=This level includes the query parameter in the cache key but still trusts the X-Forwarded-Host header to build absolute URLs for assets. Since the shared cache does not include this header in its key (Unkeyed Header), an attacker can poison the asset host for all subsequent visitors. +CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT=This level demonstrates the danger of caching personalized content (driven by cookies) as public. While the content changes per user, the cache key remains the same (route-based), leading to one user's private information being served to another visitor. +CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY=This secure level demonstrates proper cache isolation. It ignores untrusted forwarding headers and marks personalized or sensitive responses as private and no-store, preventing shared caches from storing and reusing them across different users. diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js index 07a9b671c..5c0ac9b44 100644 --- a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js @@ -78,9 +78,13 @@ function getRequestUrl(includeInputs, resetCache = false) { if (resetCache) { queryParams.set("resetCache", "true"); - } else if (includeInputs) { - const banner = getInputValue("bannerInput"); - if (banner) queryParams.set("banner", banner); + } + + // Always include banner if it's visible and has a value, + // as it's often part of the cache key (Level 3+) + const banner = getInputValue("bannerInput"); + if (banner) { + queryParams.set("banner", banner); } let queryString = queryParams.toString(); From ba62756e485a0d5753c67b56a5090d0b93ccd9df Mon Sep 17 00:00:00 2001 From: luks-santos Date: Tue, 14 Apr 2026 21:26:49 -0300 Subject: [PATCH 11/20] feat: improve level 3 vulnerability visualization --- .../CachePoisoningVulnerability.java | 7 ++++ .../CachePoisoning/Common/CachePoisoning.css | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 26e56e3ed..5ecd4d54f 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -232,6 +232,13 @@ private String buildLevel3Response(String banner, HttpServletRequest request) { + "\" target=\"_blank\" rel=\"noreferrer\">" + assetUrl + "

    " + + "
    " + + "
    Security Monitor: Context Loading
    " + + " " + + "

    The browser is attempting to load the resource from the host above. Use the Network Tab to verify the origin.

    " + + "
    " + "

    The banner is now part of the cache key, but the application trusts the X-Forwarded-Host header for asset URLs.

    " + "

    If the cache ignores this header, the asset location can be poisoned.

    " + "
  • "; diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css index 219c72f42..4035e30d0 100644 --- a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css @@ -239,6 +239,39 @@ line-height: 1.5; } +.asset-preview-box { + margin: 12px 0; + padding: 10px; + background-color: rgba(231, 76, 60, 0.05); + border: 1px dashed #e74c3c; + border-radius: 4px; +} + +.asset-preview-title { + color: #c0392b; + font-weight: bold; + font-size: 12px; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 4px; +} + +.asset-preview-iframe { + width: 100%; + height: 60px; + background: white; + border: 1px solid #bdc3c7; + border-radius: 3px; +} + +.asset-preview-note { + font-size: 11px; + color: #7f8c8d; + margin-top: 6px; + font-style: italic; +} + .cache-poisoning-response h3 { margin: 0 0 10px; font-size: 16px; From 5afcb793e4743f54d0c278aa45490fcf946be765 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Thu, 16 Apr 2026 09:24:27 -0300 Subject: [PATCH 12/20] feat: expose multiple attack vectors per Cache Poisoning level --- .../CachePoisoningVulnerability.java | 35 ++- .../CachePoisoningPayload.properties | 11 + src/main/resources/i18n/messages.properties | 2 + .../resources/i18n/messages_en_US.properties | 5 +- .../CachePoisoning/Common/CachePoisoning.js | 206 +++++++++++++----- .../CachePoisoningVulnerabilityTest.java | 14 +- 6 files changed, 185 insertions(+), 88 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 5ecd4d54f..0e29d11f0 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -58,6 +58,10 @@ public class CachePoisoningVulnerability { vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, description = "CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY", payload = "CACHE_POISONING_PAYLOAD_LEVEL_1") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_1_XSS", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_1_XSS") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_1, htmlTemplate = "Common/CachePoisoning") @@ -71,10 +75,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { if (resetCache) { clearCache(); - return buildResetCacheResponse( - buildLevel1Response(null), - CACHE_CONTROL_PUBLIC - ); + return buildResetCacheResponse(buildLevel1Response(null), CACHE_CONTROL_PUBLIC); } String responseContent = buildLevel1Response(banner); return buildCachedResponse( @@ -85,6 +86,10 @@ public ResponseEntity> getVulnerablePay vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, description = "CACHE_POISONING_LEVEL_2_NAIVE_FILTER", payload = "CACHE_POISONING_PAYLOAD_LEVEL_2") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_2_XSS", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_2_XSS") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_2, htmlTemplate = "Common/CachePoisoning") @@ -98,10 +103,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { if (resetCache) { clearCache(); - return buildResetCacheResponse( - buildLevel2Response(null), - CACHE_CONTROL_PUBLIC - ); + return buildResetCacheResponse(buildLevel2Response(null), CACHE_CONTROL_PUBLIC); } String responseContent = buildLevel2Response(banner); return buildCachedResponse( @@ -112,6 +114,10 @@ public ResponseEntity> getVulnerablePay vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, description = "CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST", payload = "CACHE_POISONING_PAYLOAD_LEVEL_3") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_3_JS_INJECTION", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_3_JS_INJECTION") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_3, htmlTemplate = "Common/CachePoisoning") @@ -126,9 +132,7 @@ public ResponseEntity> getVulnerablePay if (resetCache) { clearCache(); return buildResetCacheResponse( - buildLevel3Response(null, request), - CACHE_CONTROL_PUBLIC - ); + buildLevel3Response(null, request), CACHE_CONTROL_PUBLIC); } String responseContent = buildLevel3Response(banner, request); return buildCachedResponse( @@ -154,10 +158,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { if (resetCache) { clearCache(); - return buildResetCacheResponse( - buildLevel4Response(request), - CACHE_CONTROL_PUBLIC - ); + return buildResetCacheResponse(buildLevel4Response(request), CACHE_CONTROL_PUBLIC); } String responseContent = buildLevel4Response(request); return buildCachedResponse( @@ -183,9 +184,7 @@ public ResponseEntity> getSecurePayload if (resetCache) { clearCache(); return buildResetCacheResponse( - buildLevel5Response(null, request), - CACHE_CONTROL_PRIVATE_NO_STORE - ); + buildLevel5Response(null, request), CACHE_CONTROL_PRIVATE_NO_STORE); } String responseContent = buildLevel5Response(banner, request); return buildCachedResponse( diff --git a/src/main/resources/attackvectors/CachePoisoningPayload.properties b/src/main/resources/attackvectors/CachePoisoningPayload.properties index e0ab30c95..2fa549fd2 100644 --- a/src/main/resources/attackvectors/CachePoisoningPayload.properties +++ b/src/main/resources/attackvectors/CachePoisoningPayload.properties @@ -3,16 +3,27 @@ CACHE_POISONING_PAYLOAD_LEVEL_1=This level simulates a shared cache that stores 1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=FAKE%20URGENT%20UPDATE to poison the cache.
    \ 2. Call /VulnerableApp/CachePoisoning/LEVEL_1 without the query parameter.
    \ 3. The second response is served from cache and still contains the poisoned banner. +CACHE_POISONING_PAYLOAD_LEVEL_1_XSS=Since the banner is reflected without escaping, we can inject a script.
    \ +How to exploit?
    \ +1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=%3Cscript%3Ealert(document.cookie)%3C/script%3E.
    \ +2. All users accessing /VulnerableApp/CachePoisoning/LEVEL_1 will now execute the script due to the poisoned cache. CACHE_POISONING_PAYLOAD_LEVEL_2=This level adds a naive filter that removes only obvious script payloads, but the cache key is still based on the route alone.
    \ How to exploit this level?
    \ 1. Call /VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cb%3EURGENT%20STATUS%20UPDATE%3C/b%3E to poison the cache with harmless-looking HTML.
    \ 2. Call /VulnerableApp/CachePoisoning/LEVEL_2 without the query parameter.
    \ 3. The victim still receives the cached attacker-controlled response even though the app tried to filter the input. +CACHE_POISONING_PAYLOAD_LEVEL_2_XSS=Obvious script tags are filtered, but we can use HTML event handlers.
    \ +How to exploit?
    \ +1. Call /VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cimg%20src=%22x%22%20onerror=%22alert(document.cookie)%22%3E.
    \ +2. The server-side filter misses this, and the cache stores the malicious image tag, causing XSS for everyone. CACHE_POISONING_PAYLOAD_LEVEL_3=This level fixes the banner cache key, but still trusts X-Forwarded-Host when building absolute URLs.
    \ How to exploit this level?
    \ 1. Call /VulnerableApp/CachePoisoning/LEVEL_3?banner=shared-status-page with header X-Forwarded-Host: attacker.example.
    \ 2. Call /VulnerableApp/CachePoisoning/LEVEL_3?banner=shared-status-page again without that header.
    \ 3. The victim still receives the cached response containing the attacker-controlled host. +CACHE_POISONING_PAYLOAD_LEVEL_3_JS_INJECTION=By poisoning the X-Forwarded-Host, we control where assets are loaded from.
    \ +Payload: Set header X-Forwarded-Host: evil-script-server.com while requesting /VulnerableApp/CachePoisoning/LEVEL_3.
    \ +Impact: The browser will load external JS from the malicious host, leading to full page compromise (XSS). CACHE_POISONING_PAYLOAD_LEVEL_4=This level stops trusting forwarding headers, but still caches personalized content publicly.
    \ How to exploit this level?
    \ 1. Call /VulnerableApp/CachePoisoning/LEVEL_4 with cookie demo_user=admin.
    \ diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 197d1e5ba..59d5e2b04 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -380,7 +380,9 @@ Important Links:
    \
  • Youtube: Novel Web Cache Poisoning Techniques \ CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=This level demonstrates a cache key that only includes the request path (route). The application reflects a query parameter in the response but does not include it in the cache key. An attacker can poison the resource for all users by providing a malicious parameter in the first request. +CACHE_POISONING_LEVEL_1_XSS=Cross-Site Scripting (XSS) via Cache Poisoning. Since the banner parameter is reflected without sanitization and the cache only keys on the route, an attacker can store a malicious script in the cache that will execute for all subsequent users. CACHE_POISONING_LEVEL_2_NAIVE_FILTER=This level attempts to fix the issue by stripping script tags from the input, but the cache key still only uses the route. Attackers can still poison the cache with misleading content or other HTML payloads that bypass the naive filter. +CACHE_POISONING_LEVEL_2_XSS=XSS via Cache Poisoning with Filter Bypass. The application strips obvious "; + private static final String EVENT_HANDLER_BYPASS_BANNER = ""; + private static final String JAVASCRIPT_SCHEME_BANNER = + "click"; + private static final Pattern LAST_LOGIN_IP_PATTERN = + Pattern.compile("Last login IP:\\s*(10\\.\\d+\\.\\d+\\.42)"); private CachePoisoningVulnerability cachePoisoningVulnerability; @@ -106,6 +114,28 @@ void level1ShouldExposePublicCacheHeaders() { assertThat(response.getBody().getContent()).contains(SAFE_BANNER); } + @Test + @DisplayName("Level 1 - Reflected script payload is cached and replayed to the victim") + void level1ShouldCacheReflectedScriptPayloadForVictim() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + XSS_SCRIPT_BANNER, false, createLevel1Request("xss")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, false, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); + } + @Test @DisplayName( "Level 1 - Reset parameter clears the shared cache and returns the default response without storing it") @@ -211,6 +241,45 @@ void level2ShouldKeepUsingOnlyRouteAsCacheKey() { assertThat(secondResponse.getBody().getContent()).doesNotContain("SECOND BANNER"); } + @Test + @DisplayName("Level 2 - Naive filter does not strip event handlers, allowing XSS through cache") + void level2FilterShouldNotStripEventHandlerXssPayload() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + EVENT_HANDLER_BYPASS_BANNER, + false, + createLevelRequest(LEVEL_2_PATH, "bypass")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, false, createLevelRequest(LEVEL_2_PATH, null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(EVENT_HANDLER_BYPASS_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(EVENT_HANDLER_BYPASS_BANNER); + } + + @Test + @DisplayName("Level 2 - Naive filter strips the javascript: scheme but keeps surrounding markup") + void level2FilterShouldStripJavascriptScheme() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + JAVASCRIPT_SCHEME_BANNER, + false, + createLevelRequest(LEVEL_2_PATH, "scheme")); + + assertValidResponse(response); + assertThat(response.getBody().getContent()) + .contains("click") + .doesNotContain("javascript:"); + } + @Test @DisplayName( "Level 2 - Reset parameter clears the shared cache and restores the default banner") @@ -406,6 +475,81 @@ void level4ShouldLeakPersonalizedContentAcrossUsers() { assertThat(victimResponse.getBody().getContent()).doesNotContain("guest"); } + @Test + @DisplayName("Level 4 - Derived email leaks from the personalized response across users") + void level4ShouldLeakDerivedEmailAcrossUsers() { + String expectedEmail = + ATTACKER_USER + "@" + CachePoisoningVulnerability.DEMO_USER_DOMAIN; + + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, + createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(expectedEmail); + } + + @Test + @DisplayName("Level 4 - Last login IP is deterministically derived per user") + void level4ShouldDeriveDeterministicLastLoginIp() { + String firstBody = + cachePoisoningVulnerability + .getVulnerablePayloadLevel4( + false, + createLevelRequest( + LEVEL_4_PATH, + null, + null, + demoUserCookie(ATTACKER_USER))) + .getBody() + .getContent(); + cachePoisoningVulnerability.clearCache(); + String secondBody = + cachePoisoningVulnerability + .getVulnerablePayloadLevel4( + false, + createLevelRequest( + LEVEL_4_PATH, + null, + null, + demoUserCookie(ATTACKER_USER))) + .getBody() + .getContent(); + + Matcher firstMatcher = LAST_LOGIN_IP_PATTERN.matcher(firstBody); + Matcher secondMatcher = LAST_LOGIN_IP_PATTERN.matcher(secondBody); + + assertThat(firstMatcher.find()).isTrue(); + assertThat(secondMatcher.find()).isTrue(); + assertThat(firstMatcher.group(1)).isEqualTo(secondMatcher.group(1)); + } + + @Test + @DisplayName("Level 4 - Guest defaults are rendered when no demo_user cookie is present") + void level4ShouldRenderGuestDefaultsWhenCookieIsAbsent() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel4( + false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + + String guestEmail = + CachePoisoningVulnerability.DEFAULT_DEMO_USER + + "@" + + CachePoisoningVulnerability.DEMO_USER_DOMAIN; + + assertValidResponse(response); + assertThat(response.getBody().getContent()) + .contains(CachePoisoningVulnerability.DEFAULT_DEMO_USER) + .contains(guestEmail); + } + @Test @DisplayName( "Level 4 - Reset parameter clears the shared cache and removes the personalized response") From cd2b4a416772bd1bff0914fa6f09bdf271053d0f Mon Sep 17 00:00:00 2001 From: luks-santos Date: Fri, 17 Apr 2026 08:45:21 -0300 Subject: [PATCH 16/20] fix: format code --- .../CachePoisoningVulnerabilityTest.java | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java index a2191bf34..a5d2121d7 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -266,7 +266,8 @@ void level2FilterShouldNotStripEventHandlerXssPayload() { } @Test - @DisplayName("Level 2 - Naive filter strips the javascript: scheme but keeps surrounding markup") + @DisplayName( + "Level 2 - Naive filter strips the javascript: scheme but keeps surrounding markup") void level2FilterShouldStripJavascriptScheme() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel2( @@ -478,12 +479,10 @@ void level4ShouldLeakPersonalizedContentAcrossUsers() { @Test @DisplayName("Level 4 - Derived email leaks from the personalized response across users") void level4ShouldLeakDerivedEmailAcrossUsers() { - String expectedEmail = - ATTACKER_USER + "@" + CachePoisoningVulnerability.DEMO_USER_DOMAIN; + String expectedEmail = ATTACKER_USER + "@" + CachePoisoningVulnerability.DEMO_USER_DOMAIN; cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, - createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + false, createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( false, createLevelRequest(LEVEL_4_PATH, null, null, null)); @@ -505,10 +504,7 @@ void level4ShouldDeriveDeterministicLastLoginIp() { .getVulnerablePayloadLevel4( false, createLevelRequest( - LEVEL_4_PATH, - null, - null, - demoUserCookie(ATTACKER_USER))) + LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() .getContent(); cachePoisoningVulnerability.clearCache(); @@ -517,10 +513,7 @@ void level4ShouldDeriveDeterministicLastLoginIp() { .getVulnerablePayloadLevel4( false, createLevelRequest( - LEVEL_4_PATH, - null, - null, - demoUserCookie(ATTACKER_USER))) + LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() .getContent(); From 5e7d8bc3c3af9959b81c62fc6a767a5f5452b574 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Sat, 25 Apr 2026 18:04:29 -0300 Subject: [PATCH 17/20] Refactor cache poisoning templates by level --- .../CachePoisoningVulnerability.java | 10 +- src/main/resources/i18n/messages.properties | 2 + .../resources/i18n/messages_en_US.properties | 2 + .../CachePoisoning/Common/CachePoisoning.js | 310 ------------------ .../Common/CachePoisoningCommon.js | 114 +++++++ .../CachePoisoning/LEVEL_1/CachePoisoning.css | 1 + .../LEVEL_1/CachePoisoning.html | 57 ++++ .../CachePoisoning/LEVEL_1/CachePoisoning.js | 36 ++ .../CachePoisoning/LEVEL_2/CachePoisoning.css | 1 + .../LEVEL_2/CachePoisoning.html | 57 ++++ .../CachePoisoning/LEVEL_2/CachePoisoning.js | 36 ++ .../CachePoisoning/LEVEL_3/CachePoisoning.css | 1 + .../LEVEL_3/CachePoisoning.html | 76 +++++ .../CachePoisoning/LEVEL_3/CachePoisoning.js | 37 +++ .../CachePoisoning/LEVEL_4/CachePoisoning.css | 1 + .../LEVEL_4/CachePoisoning.html | 65 ++++ .../CachePoisoning/LEVEL_4/CachePoisoning.js | 44 +++ .../CachePoisoning/LEVEL_5/CachePoisoning.css | 1 + .../{Common => LEVEL_5}/CachePoisoning.html | 45 ++- .../CachePoisoning/LEVEL_5/CachePoisoning.js | 45 +++ 20 files changed, 602 insertions(+), 339 deletions(-) delete mode 100644 src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.js create mode 100644 src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.css create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.css rename src/main/resources/static/templates/CachePoisoning/{Common => LEVEL_5}/CachePoisoning.html (62%) create mode 100644 src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index f22fd7952..d1e6ca423 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -69,7 +69,7 @@ public class CachePoisoningVulnerability { payload = "CACHE_POISONING_PAYLOAD_LEVEL_1_XSS") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_1, - htmlTemplate = "Common/CachePoisoning") + htmlTemplate = "LEVEL_1/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -97,7 +97,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_2_XSS") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_2, - htmlTemplate = "Common/CachePoisoning") + htmlTemplate = "LEVEL_2/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -125,7 +125,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_3_JS_INJECTION") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_3, - htmlTemplate = "Common/CachePoisoning") + htmlTemplate = "LEVEL_3/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, @RequestParam( @@ -153,7 +153,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_4") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_4, - htmlTemplate = "Common/CachePoisoning") + htmlTemplate = "LEVEL_4/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel4( @RequestParam( value = RESET_CACHE_QUERY_PARAMETER, @@ -176,7 +176,7 @@ public ResponseEntity> getVulnerablePay payload = "CACHE_POISONING_PAYLOAD_LEVEL_5") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_5, - htmlTemplate = "Common/CachePoisoning", + htmlTemplate = "LEVEL_5/CachePoisoning", variant = Variant.SECURE) public ResponseEntity> getSecurePayloadLevel5( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 8966c7c99..53453efa6 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -226,6 +226,7 @@ of the application and result in complete compromise of the system.

    I #### AttackVector description JWT_URL_EXPOSING_SECURE_INFORMATION=The request contains JWT token which is leaked in the URL. This can violate PCI and most organizational compliance policies. +HEADER_INJECTION_VULNERABILITY=It tests how a JWT header can be manipulated to alter the signature verification. COOKIE_CONTAINING_JWT_TOKEN_SECURITY_ATTRIBUTES_MISSING=Cookie based JWT token but without Secure/HttpOnly flags and also without cookie prefixes. COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY=Cookie based JWT token but with HttpOnly flag but without Secure flag and also without cookie prefixes. COOKIE_BASED_LOW_KEY_STRENGTH_JWT_VULNERABILITY=Cookie based JWT token signed using Weak key vulnerability. @@ -414,6 +415,7 @@ CACHE_POISONING_LEVEL_1_XSS=Cross-Site Scripting (XSS) via Cache Poisoning. Sinc CACHE_POISONING_LEVEL_2_NAIVE_FILTER=This level attempts to fix the issue by stripping script tags from the input, but the cache key still only uses the route. Attackers can still poison the cache with misleading content or other HTML payloads that bypass the naive filter. CACHE_POISONING_LEVEL_2_XSS=XSS via Cache Poisoning with Filter Bypass. The application strips obvious ATUALIZACAO URGENTE FAKE"; @@ -42,7 +45,6 @@ class CachePoisoningVulnerabilityTest { @BeforeEach void setUp() { cachePoisoningVulnerability = new CachePoisoningVulnerability(); - cachePoisoningVulnerability.clearCache(); } @Test @@ -50,10 +52,10 @@ void setUp() { void level1ShouldPoisonSharedCacheAcrossRequests() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, false, createLevel1Request("poison")); + ATTACKER_BANNER, createLevel1Request("poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, false, createLevel1Request(null)); + null, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat( @@ -77,10 +79,10 @@ void level1ShouldPoisonSharedCacheAcrossRequests() { void level1ShouldUseOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "FIRST BANNER", false, createLevel1Request("FIRST")); + "FIRST BANNER", createLevel1Request("FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "SECOND BANNER", false, createLevel1Request("SECOND")); + "SECOND BANNER", createLevel1Request("SECOND")); assertThat( firstResponse @@ -106,7 +108,7 @@ void level1ShouldUseOnlyRouteAsCacheKey() { void level1ShouldExposePublicCacheHeaders() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, false, createLevel1Request(null)); + null, createLevel1Request(null)); assertValidResponse(response); assertThat(response.getHeaders().getCacheControl()) @@ -119,10 +121,10 @@ void level1ShouldExposePublicCacheHeaders() { void level1ShouldCacheReflectedScriptPayloadForVictim() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - XSS_SCRIPT_BANNER, false, createLevel1Request("xss")); + XSS_SCRIPT_BANNER, createLevel1Request("xss")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, false, createLevel1Request(null)); + null, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); @@ -138,33 +140,23 @@ void level1ShouldCacheReflectedScriptPayloadForVictim() { @Test @DisplayName( - "Level 1 - Reset parameter clears the shared cache and returns the default response without storing it") - void level1ShouldResetCacheWhenRequested() { + "Level 1 - clearCache endpoint clears the shared cache and returns the default response") + void level1ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, false, createLevel1Request("poison")); - ResponseEntity> resetResponse = - cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, true, createLevel1Request(null, true)); + ATTACKER_BANNER, createLevel1Request("poison")); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_1, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, false, createLevel1Request(null)); + null, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); - assertValidResponse(resetResponse); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); - assertThat(resetResponse.getBody().getContent()) + assertClearCacheResponse(clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(clearResponse.getBody().getContent()) .contains(SAFE_BANNER) .doesNotContain(ATTACKER_BANNER); @@ -184,12 +176,10 @@ void level1ShouldResetCacheWhenRequested() { void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, - false, - createLevelRequest(LEVEL_2_PATH, "poison")); + FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, false, createLevelRequest(LEVEL_2_PATH, null)); + null, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat( @@ -217,10 +207,10 @@ void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { void level2ShouldKeepUsingOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - LEVEL_2_SAFE_HTML_BANNER, false, createLevelRequest(LEVEL_2_PATH, "FIRST")); + LEVEL_2_SAFE_HTML_BANNER, createLevelRequest(LEVEL_2_PATH, "FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - "SECOND BANNER", false, createLevelRequest(LEVEL_2_PATH, "SECOND")); + "SECOND BANNER", createLevelRequest(LEVEL_2_PATH, "SECOND")); assertThat( firstResponse @@ -246,12 +236,10 @@ void level2ShouldKeepUsingOnlyRouteAsCacheKey() { void level2FilterShouldNotStripEventHandlerXssPayload() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - EVENT_HANDLER_BYPASS_BANNER, - false, - createLevelRequest(LEVEL_2_PATH, "bypass")); + EVENT_HANDLER_BYPASS_BANNER, createLevelRequest(LEVEL_2_PATH, "bypass")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, false, createLevelRequest(LEVEL_2_PATH, null)); + null, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(EVENT_HANDLER_BYPASS_BANNER); @@ -271,9 +259,7 @@ void level2FilterShouldNotStripEventHandlerXssPayload() { void level2FilterShouldStripJavascriptScheme() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - JAVASCRIPT_SCHEME_BANNER, - false, - createLevelRequest(LEVEL_2_PATH, "scheme")); + JAVASCRIPT_SCHEME_BANNER, createLevelRequest(LEVEL_2_PATH, "scheme")); assertValidResponse(response); assertThat(response.getBody().getContent()) @@ -283,36 +269,24 @@ void level2FilterShouldStripJavascriptScheme() { @Test @DisplayName( - "Level 2 - Reset parameter clears the shared cache and restores the default banner") - void level2ShouldResetCacheWhenRequested() { + "Level 2 - clearCache endpoint clears the shared cache and restores the default banner") + void level2ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, - false, - createLevelRequest(LEVEL_2_PATH, "poison")); - ResponseEntity> resetResponse = - cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, true, createLevelRequest(LEVEL_2_PATH, null, null, null, true)); + FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_2, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, false, createLevelRequest(LEVEL_2_PATH, null)); + null, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()) .contains("ATUALIZACAO URGENTE FAKE"); - assertValidResponse(resetResponse); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); - assertThat(resetResponse.getBody().getContent()) + assertClearCacheResponse(clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(clearResponse.getBody().getContent()) .contains(SAFE_BANNER) .doesNotContain("ATUALIZACAO URGENTE FAKE"); @@ -334,13 +308,11 @@ void level3ShouldPoisonSharedCacheThroughUnkeyedForwardedHost() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, - false, createLevelRequest( LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, - false, createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); assertValidResponse(attackerResponse); @@ -368,12 +340,10 @@ void level3ShouldIncludeBannerInCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "status-board", - false, createLevelRequest(LEVEL_3_PATH, "status-board", ATTACKER_HOST, null)); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "landing-page", - false, createLevelRequest(LEVEL_3_PATH, "landing-page", SAFE_HOST, null)); assertThat( @@ -398,38 +368,26 @@ void level3ShouldIncludeBannerInCacheKey() { @Test @DisplayName( - "Level 3 - Reset parameter clears the shared cache and removes the forwarded-host poisoning") - void level3ShouldResetCacheWhenRequested() { + "Level 3 - clearCache endpoint clears the shared cache and removes the forwarded-host poisoning") + void level3ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, - false, createLevelRequest( LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); - ResponseEntity> resetResponse = - cachePoisoningVulnerability.getVulnerablePayloadLevel3( - null, true, createLevelRequest(LEVEL_3_PATH, null, null, null, true)); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_3, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, - false, createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_HOST); - assertValidResponse(resetResponse); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); - assertThat(resetResponse.getBody().getContent()) + assertClearCacheResponse(clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(clearResponse.getBody().getContent()) .contains(CachePoisoningVulnerability.DEFAULT_UNTRUSTED_HOST) .doesNotContain(ATTACKER_HOST); @@ -449,12 +407,11 @@ void level3ShouldResetCacheWhenRequested() { void level4ShouldLeakPersonalizedContentAcrossUsers() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getHeaders().getCacheControl()) @@ -482,10 +439,10 @@ void level4ShouldLeakDerivedEmailAcrossUsers() { String expectedEmail = ATTACKER_USER + "@" + CachePoisoningVulnerability.DEMO_USER_DOMAIN; cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(victimResponse); assertThat( @@ -502,16 +459,14 @@ void level4ShouldDeriveDeterministicLastLoginIp() { String firstBody = cachePoisoningVulnerability .getVulnerablePayloadLevel4( - false, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() .getContent(); - cachePoisoningVulnerability.clearCache(); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_4, createClearCacheRequest()); String secondBody = cachePoisoningVulnerability .getVulnerablePayloadLevel4( - false, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() @@ -530,7 +485,7 @@ LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) void level4ShouldRenderGuestDefaultsWhenCookieIsAbsent() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + createLevelRequest(LEVEL_4_PATH, null, null, null)); String guestEmail = CachePoisoningVulnerability.DEFAULT_DEMO_USER @@ -545,36 +500,25 @@ void level4ShouldRenderGuestDefaultsWhenCookieIsAbsent() { @Test @DisplayName( - "Level 4 - Reset parameter clears the shared cache and removes the personalized response") - void level4ShouldResetCacheWhenRequested() { + "Level 4 - clearCache endpoint clears the shared cache and removes the personalized response") + void level4ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); - ResponseEntity> resetResponse = - cachePoisoningVulnerability.getVulnerablePayloadLevel4( - true, createLevelRequest(LEVEL_4_PATH, null, null, null, true)); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_4, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - false, createLevelRequest(LEVEL_4_PATH, null, null, null)); + createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_USER); - assertValidResponse(resetResponse); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); - assertThat(resetResponse.getBody().getContent()) - .contains("guest") + assertClearCacheResponse(clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(clearResponse.getBody().getContent()) + .contains(CachePoisoningVulnerability.DEFAULT_DEMO_USER) .doesNotContain(ATTACKER_USER); assertValidResponse(victimResponse); @@ -584,7 +528,7 @@ void level4ShouldResetCacheWhenRequested() { .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); assertThat(victimResponse.getBody().getContent()) - .contains("guest") + .contains(CachePoisoningVulnerability.DEFAULT_DEMO_USER) .doesNotContain(ATTACKER_USER); } @@ -595,7 +539,6 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { ResponseEntity> personalizedResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( "OWNED", - false, createLevelRequest( LEVEL_5_PATH, "OWNED", @@ -603,7 +546,7 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { demoUserCookie(ATTACKER_USER))); ResponseEntity> guestResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( - null, false, createLevelRequest(LEVEL_5_PATH, null, null, null)); + null, createLevelRequest(LEVEL_5_PATH, null, null, null)); assertValidResponse(personalizedResponse); assertThat(personalizedResponse.getHeaders().getCacheControl()) @@ -630,42 +573,31 @@ void level5ShouldAvoidSharedCacheMixingAndIgnoreUntrustedHeaders() { } @Test - @DisplayName("Level 5 - Reset parameter returns a fresh safe response with an empty cache key") - void level5ShouldSupportResetRequest() { + @DisplayName( + "Level 5 - clearCache endpoint returns a fresh safe response with an empty cache key") + void level5ShouldClearCacheWhenRequested() { ResponseEntity> personalizedResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( "OWNED", - false, createLevelRequest( LEVEL_5_PATH, "OWNED", ATTACKER_HOST, demoUserCookie(ATTACKER_USER))); - ResponseEntity> resetResponse = - cachePoisoningVulnerability.getSecurePayloadLevel5( - null, true, createLevelRequest(LEVEL_5_PATH, null, null, null, true)); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_5, createClearCacheRequest()); ResponseEntity> guestResponse = cachePoisoningVulnerability.getSecurePayloadLevel5( - null, false, createLevelRequest(LEVEL_5_PATH, null, null, null)); + null, createLevelRequest(LEVEL_5_PATH, null, null, null)); assertValidResponse(personalizedResponse); assertThat(personalizedResponse.getBody().getContent()).contains(ATTACKER_USER); - assertValidResponse(resetResponse); - assertThat(resetResponse.getHeaders().getCacheControl()) - .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PRIVATE_NO_STORE); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); - assertThat( - resetResponse - .getHeaders() - .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); - assertThat(resetResponse.getBody().getContent()) - .contains("guest") + assertClearCacheResponse( + clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PRIVATE_NO_STORE); + assertThat(clearResponse.getBody().getContent()) + .contains(CachePoisoningVulnerability.DEFAULT_DEMO_USER) .contains(CachePoisoningVulnerability.TRUSTED_ASSET_HOST) .doesNotContain(ATTACKER_USER) .doesNotContain(ATTACKER_HOST); @@ -677,10 +609,54 @@ void level5ShouldSupportResetRequest() { .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); assertThat(guestResponse.getBody().getContent()) - .contains("guest") + .contains(CachePoisoningVulnerability.DEFAULT_DEMO_USER) .doesNotContain(ATTACKER_USER); } + @Test + @DisplayName("clearCache only evicts entries belonging to the targeted level") + void clearCacheShouldOnlyEvictTargetedLevel() { + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + ATTACKER_BANNER, createLevel1Request("poison-l1")); + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison-l2")); + + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_1, createClearCacheRequest()); + + ResponseEntity> level1Victim = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, createLevel1Request(null)); + ResponseEntity> level2Victim = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, createLevelRequest(LEVEL_2_PATH, null)); + + assertThat( + level1Victim + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(level1Victim.getBody().getContent()).doesNotContain(ATTACKER_BANNER); + + assertThat( + level2Victim + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(level2Victim.getBody().getContent()).contains("ATUALIZACAO URGENTE FAKE"); + } + + @Test + @DisplayName("clearCache returns 400 when the level is unsupported") + void clearCacheShouldRejectUnsupportedLevel() { + ResponseEntity> response = + cachePoisoningVulnerability.clearCache("LEVEL_99", createClearCacheRequest()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getIsValid()).isFalse(); + assertThat(response.getBody().getContent()).isEqualTo("Unsupported level"); + } + private void assertValidResponse( ResponseEntity> responseEntity) { assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -688,12 +664,26 @@ private void assertValidResponse( assertThat(responseEntity.getBody().getIsValid()).isTrue(); } - private MockHttpServletRequest createLevel1Request(String queryString) { - return createLevel1Request(queryString, false); + private void assertClearCacheResponse( + ResponseEntity> response, + String expectedCacheControl) { + assertValidResponse(response); + assertThat(response.getHeaders().getCacheControl()).isEqualTo(expectedCacheControl); + assertThat(response.getHeaders().getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_STATUS); + assertThat(response.getHeaders().getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(CachePoisoningVulnerability.EMPTY_CACHE_KEY); } - private MockHttpServletRequest createLevel1Request(String queryString, boolean resetCache) { - return createLevelRequest(LEVEL_1_PATH, queryString, null, null, resetCache); + private MockHttpServletRequest createClearCacheRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI(CLEAR_CACHE_PATH); + return request; + } + + private MockHttpServletRequest createLevel1Request(String queryString) { + return createLevelRequest(LEVEL_1_PATH, queryString, null, null); } private MockHttpServletRequest createLevelRequest(String path, String queryString) { @@ -702,36 +692,13 @@ private MockHttpServletRequest createLevelRequest(String path, String queryStrin private MockHttpServletRequest createLevelRequest( String path, String queryString, String forwardedHost, Cookie cookie) { - return createLevelRequest(path, queryString, forwardedHost, cookie, false); - } - - private MockHttpServletRequest createLevelRequest( - String path, - String queryString, - String forwardedHost, - Cookie cookie, - boolean resetCache) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setMethod("GET"); request.setRequestURI(path); - StringBuilder requestQueryString = new StringBuilder(); if (queryString != null) { - requestQueryString - .append(CachePoisoningVulnerability.BANNER_QUERY_PARAMETER) - .append("=") - .append(queryString); - } - if (resetCache) { - if (requestQueryString.length() > 0) { - requestQueryString.append("&"); - } - requestQueryString - .append(CachePoisoningVulnerability.RESET_CACHE_QUERY_PARAMETER) - .append("=true"); - } - if (requestQueryString.length() > 0) { - request.setQueryString(requestQueryString.toString()); + request.setQueryString( + CachePoisoningVulnerability.BANNER_QUERY_PARAMETER + "=" + queryString); } if (forwardedHost != null) { request.addHeader(CachePoisoningVulnerability.FORWARDED_HOST_HEADER, forwardedHost); From de130b0ebbb5ea04de228f531411f0ed64a37f89 Mon Sep 17 00:00:00 2001 From: luks-santos Date: Sun, 26 Apr 2026 10:21:42 -0300 Subject: [PATCH 19/20] Fix cache poisoning cache reset behavior --- .../cachePoisoning/CachePoisoningVulnerability.java | 2 +- .../CachePoisoning/Common/CachePoisoningCommon.js | 7 ------- .../templates/CachePoisoning/LEVEL_1/CachePoisoning.html | 2 +- .../templates/CachePoisoning/LEVEL_2/CachePoisoning.html | 2 +- .../templates/CachePoisoning/LEVEL_3/CachePoisoning.html | 2 +- .../templates/CachePoisoning/LEVEL_4/CachePoisoning.html | 2 +- .../templates/CachePoisoning/LEVEL_5/CachePoisoning.html | 2 +- 7 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 7b67e7108..94ee70870 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -58,7 +58,7 @@ public class CachePoisoningVulnerability { Pattern.compile("(?is)<\\s*/?\\s*script\\b[^>]*>"); private static final Pattern JAVASCRIPT_SCHEME_PATTERN = Pattern.compile("(?i)javascript\\s*:"); - private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); @AttackVector( vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js index 637b77597..10ed603d0 100644 --- a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js @@ -93,7 +93,6 @@ function updateDiagnostics(request) { const varyHeaderEl = document.getElementById("varyHeader"); if (varyHeaderEl) varyHeaderEl.textContent = vary; - updateResetCacheButton(cacheStatus, cacheKey); } function updateCacheStatusIndicator(cacheStatus) { @@ -114,9 +113,3 @@ function updateCacheStatusIndicator(cacheStatus) { else el.classList.add("cache-status-neutral"); } -function updateResetCacheButton(cacheStatus, cacheKey) { - const btn = document.getElementById("resetCacheBtn"); - const hasCache = - cacheKey !== "-" && cacheStatus !== "-" && cacheStatus !== ""; - btn.disabled = !hasCache; -} diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html index 1c58764e2..4ff15dd3a 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html @@ -24,7 +24,7 @@
    Controls
    - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html index 6c1ed17a3..6a7982e09 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html @@ -24,7 +24,7 @@
    Controls
    - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html index 4775d0a8f..6d7bb7eac 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html @@ -35,7 +35,7 @@
    Controls
    - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html index 5396b706b..00aa6e91a 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html @@ -24,7 +24,7 @@
    Controls
    - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html index 860785fc2..354b581a2 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.html @@ -46,7 +46,7 @@
    Controls
    - +
    From 4178173023651f56f9b67e17d1d88066d2dd48be Mon Sep 17 00:00:00 2001 From: luks-santos Date: Mon, 27 Apr 2026 21:26:19 -0300 Subject: [PATCH 20/20] feat: add browser cache toggle to cache poisoning levels 1-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a per-level toggle that switches between two Cache-Control modes on the server response: - ON (default, realistic): public, max-age=60 — browser and shared cache - OFF (student-friendly): public, s-maxage=60, max-age=0 — shared cache only, browser always revalidates, scanners still detect via s-maxage --- .../CachePoisoningVulnerability.java | 33 ++++++-- .../CachePoisoning/Common/CachePoisoning.css | 23 +++++ .../Common/CachePoisoningCommon.js | 34 ++------ .../LEVEL_1/CachePoisoning.html | 12 +++ .../CachePoisoning/LEVEL_1/CachePoisoning.js | 10 +-- .../LEVEL_2/CachePoisoning.html | 12 +++ .../CachePoisoning/LEVEL_2/CachePoisoning.js | 10 +-- .../LEVEL_3/CachePoisoning.html | 8 ++ .../CachePoisoning/LEVEL_3/CachePoisoning.js | 8 +- .../LEVEL_4/CachePoisoning.html | 8 ++ .../CachePoisoning/LEVEL_4/CachePoisoning.js | 6 +- .../CachePoisoning/LEVEL_5/CachePoisoning.js | 8 +- .../CachePoisoningVulnerabilityTest.java | 83 +++++++++++++------ 13 files changed, 162 insertions(+), 93 deletions(-) diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 94ee70870..fb49614c0 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -40,6 +40,7 @@ public class CachePoisoningVulnerability { static final String CACHE_STATUS_HIT = "HIT"; static final String CACHE_STATUS_MISS = "MISS"; static final String CACHE_CONTROL_PUBLIC = "public, max-age=60"; + static final String CACHE_CONTROL_PUBLIC_SHARED_ONLY = "public, s-maxage=60, max-age=0"; static final String CACHE_CONTROL_PRIVATE_NO_STORE = "private, no-store"; static final String DEFAULT_BANNER = "Welcome to the shared cache demo"; @@ -58,7 +59,8 @@ public class CachePoisoningVulnerability { Pattern.compile("(?is)<\\s*/?\\s*script\\b[^>]*>"); private static final Pattern JAVASCRIPT_SCHEME_PATTERN = Pattern.compile("(?i)javascript\\s*:"); - private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap cache = + new ConcurrentHashMap<>(); @AttackVector( vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, @@ -73,10 +75,15 @@ public class CachePoisoningVulnerability { htmlTemplate = "LEVEL_1/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel1Response(banner); return buildCachedResponse( - buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); } @AttackVector( @@ -92,10 +99,15 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_2/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel2Response(banner); return buildCachedResponse( - buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); } @AttackVector( @@ -111,12 +123,14 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_3/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel3Response(banner, request); return buildCachedResponse( buildRouteAndBannerCacheKey(request, banner), responseContent, - CACHE_CONTROL_PUBLIC, + resolvePublicCacheControl(browserCache), true); } @@ -128,10 +142,15 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_4/CachePoisoning") public ResponseEntity> getVulnerablePayloadLevel4( + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel4Response(request); return buildCachedResponse( - buildRouteOnlyCacheKey(request), responseContent, CACHE_CONTROL_PUBLIC, true); + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); } @AttackVector( @@ -361,6 +380,10 @@ private String resolveDemoUser(HttpServletRequest request) { return null; } + private String resolvePublicCacheControl(boolean browserCache) { + return browserCache ? CACHE_CONTROL_PUBLIC : CACHE_CONTROL_PUBLIC_SHARED_ONLY; + } + private String buildAssetUrl(String host) { return "https://" + StringEscapeUtils.escapeHtml4(host) + "/assets/cache-poisoning-demo.js"; } diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css index 4035e30d0..6ddc653ae 100644 --- a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoning.css @@ -160,6 +160,29 @@ text-align: center; } +.cache-poisoning-toggle-row { + display: flex; + align-items: center; + gap: 10px; + padding-top: 10px; + font-size: 12px; + flex-wrap: wrap; +} + +.cache-poisoning-toggle-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: bold; + cursor: pointer; + white-space: nowrap; +} + +.cache-poisoning-toggle-hint { + color: #555; + font-style: italic; +} + .cache-poisoning-action-group button:disabled { background: #c7c7c7; color: #555; diff --git a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js index 10ed603d0..4d89d7b9e 100644 --- a/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js +++ b/src/main/resources/static/templates/CachePoisoning/Common/CachePoisoningCommon.js @@ -10,16 +10,13 @@ export function clearInputs(ids) { }); } -export function getNoBrowserCacheHeaders(headers = {}) { - return { - ...headers, - "Cache-Control": "no-store, no-cache, max-age=0", - Pragma: "no-cache", - }; +export function getBrowserCacheEnabled() { + const el = document.getElementById("browserCacheToggle"); + return el ? el.checked : true; } export function getRequestUrl(options = {}) { - const { bannerInputId = "bannerInput", browserCacheBust = true } = options; + const { bannerInputId = "bannerInput" } = options; const queryParams = new URLSearchParams(); const banner = bannerInputId ? getInputValue(bannerInputId) : ""; @@ -27,12 +24,7 @@ export function getRequestUrl(options = {}) { queryParams.set("banner", banner); } - if (browserCacheBust) { - queryParams.set( - "_browserCacheBust", - `${Date.now()}-${Math.random().toString(16).slice(2)}` - ); - } + queryParams.set("browserCache", String(getBrowserCacheEnabled())); const queryString = queryParams.toString(); const url = getUrlForVulnerabilityLevel(); @@ -46,22 +38,12 @@ export function clearCacheAndFetchFreshResponse() { "?level=" + encodeURIComponent(getCurrentVulnerabilityLevel()); - doPostAjaxCall( - fetchDataCallback, - clearCacheUrl, - true, - null, - getNoBrowserCacheHeaders() - ); + doPostAjaxCall(fetchDataCallback, clearCacheUrl, true, null, {}); } export function getHeadersWithForwardedHost(inputId = "forwardedHostInput") { - const headers = getNoBrowserCacheHeaders(); const forwardedHost = getInputValue(inputId); - if (forwardedHost) { - headers["X-Forwarded-Host"] = forwardedHost; - } - return headers; + return forwardedHost ? { "X-Forwarded-Host": forwardedHost } : {}; } export function setDemoUserCookie(value) { @@ -92,7 +74,6 @@ function updateDiagnostics(request) { const varyHeaderEl = document.getElementById("varyHeader"); if (varyHeaderEl) varyHeaderEl.textContent = vary; - } function updateCacheStatusIndicator(cacheStatus) { @@ -112,4 +93,3 @@ function updateCacheStatusIndicator(cacheStatus) { else if (status === "MISS") el.classList.add("cache-status-miss"); else el.classList.add("cache-status-neutral"); } - diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html index 4ff15dd3a..d4920b909 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.html @@ -30,6 +30,14 @@
    Controls
    + +
    + + Off: s-maxage only +
  • @@ -44,6 +52,10 @@
    Cache Signals
    Cache key -
    +
    + Cache-Control + - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js index 08a9db039..1137dd273 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_1/CachePoisoning.js @@ -2,19 +2,13 @@ import { clearCacheAndFetchFreshResponse, clearInputs, fetchDataCallback, - getNoBrowserCacheHeaders, getRequestUrl, } from "../Common/CachePoisoningCommon.js"; document .getElementById("poisonCacheBtn") .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getRequestUrl(), - true, - getNoBrowserCacheHeaders() - ); + doGetAjaxCall(fetchDataCallback, getRequestUrl(), true, {}); clearInputs(["bannerInput"]); }); @@ -29,6 +23,6 @@ document fetchDataCallback, getRequestUrl({ bannerInputId: null }), true, - getNoBrowserCacheHeaders() + {} ); }); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html index 6a7982e09..802d9c5b5 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.html @@ -30,6 +30,14 @@
    Controls
    + +
    + + Off s-maxage only +
    @@ -44,6 +52,10 @@
    Cache Signals
    Cache key -
    +
    + Cache-Control + - +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js index 08a9db039..1137dd273 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_2/CachePoisoning.js @@ -2,19 +2,13 @@ import { clearCacheAndFetchFreshResponse, clearInputs, fetchDataCallback, - getNoBrowserCacheHeaders, getRequestUrl, } from "../Common/CachePoisoningCommon.js"; document .getElementById("poisonCacheBtn") .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getRequestUrl(), - true, - getNoBrowserCacheHeaders() - ); + doGetAjaxCall(fetchDataCallback, getRequestUrl(), true, {}); clearInputs(["bannerInput"]); }); @@ -29,6 +23,6 @@ document fetchDataCallback, getRequestUrl({ bannerInputId: null }), true, - getNoBrowserCacheHeaders() + {} ); }); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html index 6d7bb7eac..853733568 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.html @@ -41,6 +41,14 @@
    Controls
    + +
    + + Off: s-maxage only +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js index 995e31ae8..447c26922 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_3/CachePoisoning.js @@ -3,7 +3,6 @@ import { clearInputs, fetchDataCallback, getHeadersWithForwardedHost, - getNoBrowserCacheHeaders, getRequestUrl, } from "../Common/CachePoisoningCommon.js"; @@ -26,10 +25,5 @@ document.getElementById("resetCacheBtn").addEventListener("click", function () { document .getElementById("victimRequestBtn") .addEventListener("click", function () { - doGetAjaxCall( - fetchDataCallback, - getRequestUrl(), - true, - getNoBrowserCacheHeaders() - ); + doGetAjaxCall(fetchDataCallback, getRequestUrl(), true, {}); }); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html index 00aa6e91a..67785cb8d 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.html @@ -30,6 +30,14 @@
    Controls
    + +
    + + Off: s-maxage only +
    diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js index 2f04cce33..5f97d41ed 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_4/CachePoisoning.js @@ -3,7 +3,6 @@ import { clearInputs, fetchDataCallback, getInputValue, - getNoBrowserCacheHeaders, getRequestUrl, setDemoUserCookie, } from "../Common/CachePoisoningCommon.js"; @@ -15,12 +14,11 @@ document if (demoUser) { setDemoUserCookie(demoUser); } - doGetAjaxCall( fetchDataCallback, getRequestUrl({ bannerInputId: null }), true, - getNoBrowserCacheHeaders() + {} ); clearInputs(["demoUserInput"]); }); @@ -38,6 +36,6 @@ document fetchDataCallback, getRequestUrl({ bannerInputId: null }), true, - getNoBrowserCacheHeaders() + {} ); }); diff --git a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js index f5b36220e..20f89a958 100644 --- a/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js +++ b/src/main/resources/static/templates/CachePoisoning/LEVEL_5/CachePoisoning.js @@ -4,7 +4,6 @@ import { fetchDataCallback, getHeadersWithForwardedHost, getInputValue, - getNoBrowserCacheHeaders, getRequestUrl, setDemoUserCookie, } from "../Common/CachePoisoningCommon.js"; @@ -35,10 +34,5 @@ document .getElementById("victimRequestBtn") .addEventListener("click", function () { setDemoUserCookie(null); - doGetAjaxCall( - fetchDataCallback, - getRequestUrl(), - true, - getNoBrowserCacheHeaders() - ); + doGetAjaxCall(fetchDataCallback, getRequestUrl(), true, {}); }); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java index 3861e9a9f..2ce292fef 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -45,6 +45,11 @@ class CachePoisoningVulnerabilityTest { @BeforeEach void setUp() { cachePoisoningVulnerability = new CachePoisoningVulnerability(); + MockHttpServletRequest clearReq = createClearCacheRequest(); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_1, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_2, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_3, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_4, clearReq); } @Test @@ -52,10 +57,10 @@ void setUp() { void level1ShouldPoisonSharedCacheAcrossRequests() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, createLevel1Request("poison")); + ATTACKER_BANNER, true, createLevel1Request("poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, true, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat( @@ -79,10 +84,10 @@ void level1ShouldPoisonSharedCacheAcrossRequests() { void level1ShouldUseOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "FIRST BANNER", createLevel1Request("FIRST")); + "FIRST BANNER", true, createLevel1Request("FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - "SECOND BANNER", createLevel1Request("SECOND")); + "SECOND BANNER", true, createLevel1Request("SECOND")); assertThat( firstResponse @@ -108,7 +113,7 @@ void level1ShouldUseOnlyRouteAsCacheKey() { void level1ShouldExposePublicCacheHeaders() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, true, createLevel1Request(null)); assertValidResponse(response); assertThat(response.getHeaders().getCacheControl()) @@ -116,15 +121,27 @@ void level1ShouldExposePublicCacheHeaders() { assertThat(response.getBody().getContent()).contains(SAFE_BANNER); } + @Test + @DisplayName("Level 1 - browserCache=false emits shared-only Cache-Control with s-maxage") + void level1ShouldEmitSharedOnlyCacheControlWhenBrowserCacheDisabled() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, false, createLevel1Request(null)); + + assertValidResponse(response); + assertThat(response.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC_SHARED_ONLY); + } + @Test @DisplayName("Level 1 - Reflected script payload is cached and replayed to the victim") void level1ShouldCacheReflectedScriptPayloadForVictim() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - XSS_SCRIPT_BANNER, createLevel1Request("xss")); + XSS_SCRIPT_BANNER, true, createLevel1Request("xss")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, true, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); @@ -144,13 +161,13 @@ void level1ShouldCacheReflectedScriptPayloadForVictim() { void level1ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, createLevel1Request("poison")); + ATTACKER_BANNER, true, createLevel1Request("poison")); ResponseEntity> clearResponse = cachePoisoningVulnerability.clearCache( LevelConstants.LEVEL_1, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, true, createLevel1Request(null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); @@ -176,10 +193,10 @@ void level1ShouldClearCacheWhenRequested() { void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); + FILTERED_ATTACKER_BANNER, true, createLevelRequest(LEVEL_2_PATH, "poison")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, createLevelRequest(LEVEL_2_PATH, null)); + null, true, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat( @@ -207,10 +224,10 @@ void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { void level2ShouldKeepUsingOnlyRouteAsCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - LEVEL_2_SAFE_HTML_BANNER, createLevelRequest(LEVEL_2_PATH, "FIRST")); + LEVEL_2_SAFE_HTML_BANNER, true, createLevelRequest(LEVEL_2_PATH, "FIRST")); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - "SECOND BANNER", createLevelRequest(LEVEL_2_PATH, "SECOND")); + "SECOND BANNER", true, createLevelRequest(LEVEL_2_PATH, "SECOND")); assertThat( firstResponse @@ -236,10 +253,12 @@ void level2ShouldKeepUsingOnlyRouteAsCacheKey() { void level2FilterShouldNotStripEventHandlerXssPayload() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - EVENT_HANDLER_BYPASS_BANNER, createLevelRequest(LEVEL_2_PATH, "bypass")); + EVENT_HANDLER_BYPASS_BANNER, + true, + createLevelRequest(LEVEL_2_PATH, "bypass")); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, createLevelRequest(LEVEL_2_PATH, null)); + null, true, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(EVENT_HANDLER_BYPASS_BANNER); @@ -259,7 +278,7 @@ void level2FilterShouldNotStripEventHandlerXssPayload() { void level2FilterShouldStripJavascriptScheme() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - JAVASCRIPT_SCHEME_BANNER, createLevelRequest(LEVEL_2_PATH, "scheme")); + JAVASCRIPT_SCHEME_BANNER, true, createLevelRequest(LEVEL_2_PATH, "scheme")); assertValidResponse(response); assertThat(response.getBody().getContent()) @@ -273,13 +292,13 @@ void level2FilterShouldStripJavascriptScheme() { void level2ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison")); + FILTERED_ATTACKER_BANNER, true, createLevelRequest(LEVEL_2_PATH, "poison")); ResponseEntity> clearResponse = cachePoisoningVulnerability.clearCache( LevelConstants.LEVEL_2, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, createLevelRequest(LEVEL_2_PATH, null)); + null, true, createLevelRequest(LEVEL_2_PATH, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()) @@ -308,11 +327,13 @@ void level3ShouldPoisonSharedCacheThroughUnkeyedForwardedHost() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + true, createLevelRequest( LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + true, createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); assertValidResponse(attackerResponse); @@ -340,10 +361,12 @@ void level3ShouldIncludeBannerInCacheKey() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "status-board", + true, createLevelRequest(LEVEL_3_PATH, "status-board", ATTACKER_HOST, null)); ResponseEntity> secondResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( "landing-page", + true, createLevelRequest(LEVEL_3_PATH, "landing-page", SAFE_HOST, null)); assertThat( @@ -373,6 +396,7 @@ void level3ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + true, createLevelRequest( LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, ATTACKER_HOST, null)); ResponseEntity> clearResponse = @@ -381,6 +405,7 @@ void level3ShouldClearCacheWhenRequested() { ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel3( LEVEL_3_SHARED_BANNER, + true, createLevelRequest(LEVEL_3_PATH, LEVEL_3_SHARED_BANNER, null, null)); assertValidResponse(attackerResponse); @@ -407,11 +432,12 @@ void level3ShouldClearCacheWhenRequested() { void level4ShouldLeakPersonalizedContentAcrossUsers() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( + true, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - createLevelRequest(LEVEL_4_PATH, null, null, null)); + true, createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getHeaders().getCacheControl()) @@ -439,10 +465,10 @@ void level4ShouldLeakDerivedEmailAcrossUsers() { String expectedEmail = ATTACKER_USER + "@" + CachePoisoningVulnerability.DEMO_USER_DOMAIN; cachePoisoningVulnerability.getVulnerablePayloadLevel4( - createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); + true, createLevelRequest(LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - createLevelRequest(LEVEL_4_PATH, null, null, null)); + true, createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(victimResponse); assertThat( @@ -459,6 +485,7 @@ void level4ShouldDeriveDeterministicLastLoginIp() { String firstBody = cachePoisoningVulnerability .getVulnerablePayloadLevel4( + true, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() @@ -467,6 +494,7 @@ LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) String secondBody = cachePoisoningVulnerability .getVulnerablePayloadLevel4( + true, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) .getBody() @@ -485,7 +513,7 @@ LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))) void level4ShouldRenderGuestDefaultsWhenCookieIsAbsent() { ResponseEntity> response = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - createLevelRequest(LEVEL_4_PATH, null, null, null)); + true, createLevelRequest(LEVEL_4_PATH, null, null, null)); String guestEmail = CachePoisoningVulnerability.DEFAULT_DEMO_USER @@ -504,6 +532,7 @@ void level4ShouldRenderGuestDefaultsWhenCookieIsAbsent() { void level4ShouldClearCacheWhenRequested() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( + true, createLevelRequest( LEVEL_4_PATH, null, null, demoUserCookie(ATTACKER_USER))); ResponseEntity> clearResponse = @@ -511,7 +540,7 @@ void level4ShouldClearCacheWhenRequested() { LevelConstants.LEVEL_4, createClearCacheRequest()); ResponseEntity> victimResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel4( - createLevelRequest(LEVEL_4_PATH, null, null, null)); + true, createLevelRequest(LEVEL_4_PATH, null, null, null)); assertValidResponse(attackerResponse); assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_USER); @@ -617,18 +646,18 @@ void level5ShouldClearCacheWhenRequested() { @DisplayName("clearCache only evicts entries belonging to the targeted level") void clearCacheShouldOnlyEvictTargetedLevel() { cachePoisoningVulnerability.getVulnerablePayloadLevel1( - ATTACKER_BANNER, createLevel1Request("poison-l1")); + ATTACKER_BANNER, true, createLevel1Request("poison-l1")); cachePoisoningVulnerability.getVulnerablePayloadLevel2( - FILTERED_ATTACKER_BANNER, createLevelRequest(LEVEL_2_PATH, "poison-l2")); + FILTERED_ATTACKER_BANNER, true, createLevelRequest(LEVEL_2_PATH, "poison-l2")); cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_1, createClearCacheRequest()); ResponseEntity> level1Victim = cachePoisoningVulnerability.getVulnerablePayloadLevel1( - null, createLevel1Request(null)); + null, true, createLevel1Request(null)); ResponseEntity> level2Victim = cachePoisoningVulnerability.getVulnerablePayloadLevel2( - null, createLevelRequest(LEVEL_2_PATH, null)); + null, true, createLevelRequest(LEVEL_2_PATH, null)); assertThat( level1Victim