From b823da288f3fff0ba370d4525af1fddab592707f Mon Sep 17 00:00:00 2001 From: Toby Moreno Date: Sun, 9 Aug 2026 13:53:49 -0700 Subject: [PATCH] fix: isolate cache poisoning level 1 banners --- .../CachePoisoningVulnerability.java | 6 +-- .../CachePoisoningVulnerabilityTest.java | 37 +++++++++++-------- 2 files changed, 24 insertions(+), 19 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 0ab74b376..8ff216722 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -82,7 +82,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { String responseContent = buildLevel1Response(banner); return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildRouteAndBannerCacheKey(request, banner), responseContent, resolvePublicCacheControl(browserCache), true); @@ -175,11 +175,11 @@ public ResponseEntity> getSecurePayload } private String buildLevel1Response(String banner) { - String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); + String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); return "
" + "

Shared Cache Response

" + "

Current Banner: " - + unsafeBanner + + safeBanner + "

" + "

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.

" 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 2ce292fef..74613c28f 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerabilityTest.java @@ -53,8 +53,8 @@ void setUp() { } @Test - @DisplayName("Level 1 - Attacker poisons the route cache and victim gets a cached hit") - void level1ShouldPoisonSharedCacheAcrossRequests() { + @DisplayName("Level 1 - An attacker banner is isolated from a plain request") + void level1ShouldNotPoisonPlainRequests() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( ATTACKER_BANNER, true, createLevel1Request("poison")); @@ -75,13 +75,15 @@ void level1ShouldPoisonSharedCacheAcrossRequests() { victimResponse .getHeaders() .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) - .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); - assertThat(victimResponse.getBody().getContent()).contains(ATTACKER_BANNER); + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain(ATTACKER_BANNER); } @Test - @DisplayName("Level 1 - Cache key ignores the banner query parameter") - void level1ShouldUseOnlyRouteAsCacheKey() { + @DisplayName("Level 1 - Cache key includes the banner query parameter") + void level1ShouldSeparateDifferentBanners() { ResponseEntity> firstResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( "FIRST BANNER", true, createLevel1Request("FIRST")); @@ -93,19 +95,19 @@ void level1ShouldUseOnlyRouteAsCacheKey() { firstResponse .getHeaders() .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(LEVEL_1_PATH); + .isEqualTo(LEVEL_1_PATH + "|banner=FIRST BANNER"); assertThat( secondResponse .getHeaders() .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) - .isEqualTo(LEVEL_1_PATH); + .isEqualTo(LEVEL_1_PATH + "|banner=SECOND BANNER"); 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"); + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(secondResponse.getBody().getContent()).contains("SECOND BANNER"); + assertThat(secondResponse.getBody().getContent()).doesNotContain("FIRST BANNER"); } @Test @@ -134,8 +136,8 @@ void level1ShouldEmitSharedOnlyCacheControlWhenBrowserCacheDisabled() { } @Test - @DisplayName("Level 1 - Reflected script payload is cached and replayed to the victim") - void level1ShouldCacheReflectedScriptPayloadForVictim() { + @DisplayName("Level 1 - Reflected script payload is escaped and isolated") + void level1ShouldEscapeAndIsolateReflectedScriptPayload() { ResponseEntity> attackerResponse = cachePoisoningVulnerability.getVulnerablePayloadLevel1( XSS_SCRIPT_BANNER, true, createLevel1Request("xss")); @@ -144,15 +146,18 @@ void level1ShouldCacheReflectedScriptPayloadForVictim() { null, true, createLevel1Request(null)); assertValidResponse(attackerResponse); - assertThat(attackerResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); + assertThat(attackerResponse.getBody().getContent()) + .contains("<script>alert(document.cookie)</script>") + .doesNotContain(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); + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()).contains(SAFE_BANNER); + assertThat(victimResponse.getBody().getContent()).doesNotContain(XSS_SCRIPT_BANNER); } @Test