diff --git a/src/main/java/org/sasanlabs/controller/exception/ControllerExceptionHandler.java b/src/main/java/org/sasanlabs/controller/exception/ControllerExceptionHandler.java index 4e377e9cd..8ed65048f 100755 --- a/src/main/java/org/sasanlabs/controller/exception/ControllerExceptionHandler.java +++ b/src/main/java/org/sasanlabs/controller/exception/ControllerExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; /** @@ -37,6 +38,20 @@ public ResponseEntity handleControllerExceptions( HttpStatus.INTERNAL_SERVER_ERROR); } + /** + * An upload larger than the configured ceiling is a rejected request, not a server fault. + * Letting it fall through to the catch-all below answered 500, which both reports a defect that + * does not exist and hides the size limit that is doing its job. + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleUploadTooLarge( + MaxUploadSizeExceededException ex, WebRequest request) { + LOGGER.info("Rejected an upload exceeding the configured maximum size"); + return new ResponseEntity( + "Input is invalid: file exceeds the maximum permitted size", + HttpStatus.PAYLOAD_TOO_LARGE); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleExceptions(Exception ex, WebRequest request) { LOGGER.error("General Exception Occurred :- ", ex); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java index 8c23ab0dc..c84f00944 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java @@ -36,38 +36,46 @@ public AuthLoginService( this.passwordEncoder = passwordEncoder; } - /** Level 1: SQL Injection. Demonstrates a login query vulnerable to string concatenation. */ + /** + * Level 1: the credentials are bound as query parameters, so a value such as {@code ' OR + * '1'='1} is compared as a literal string instead of being parsed as SQL. The database error is + * also no longer echoed back, since it would otherwise hand an attacker error-based SQL + * injection. + */ public AuthResult authenticateLevel1SQLi(String username, String password) { - // Vulnerable query with string concatenation - String sql = - "SELECT * FROM auth_users WHERE level=1 AND username='" - + username - + "' AND password='" - + password - + "'"; + String sql = "SELECT * FROM auth_users WHERE level=1 AND username=? AND password=?"; try { - // Level 1 still uses JdbcTemplate to allow SQL Injection bypass List users = - jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(AuthUser.class)); + jdbcTemplate.query( + sql, new BeanPropertyRowMapper<>(AuthUser.class), username, password); if (!users.isEmpty()) { return AuthResult.success(users.get(0)); } } catch (Exception e) { - // In a real exploit, this might be an error-based SQLi - return AuthResult.failure("Database error: " + e.getMessage()); + LOGGER.error("Login query failed for level 1", e); + return AuthResult.failure("Invalid credentials"); } return AuthResult.failure("Invalid credentials"); } - /** Level 2: Sensitive Data Logging. Logs the provided password to the logs. */ + /** + * Level 2: the attempt is still logged for audit purposes, but the submitted password is not. + * Anyone with read access to the logs would otherwise hold every password typed at this form, + * including the near-misses that reveal a real password by a character or two. + */ public AuthResult authenticateLevel2Logging(String username, String password) { Optional userOpt = authUserRepository.findByUsernameAndLevel(username, 2); - LOGGER.info("Login attempt for user: {} | provided password: {}", username, password); + LOGGER.info("Login attempt for user: {}", username); + /* + * The stored value is a BCrypt hash, not the password, so the comparison goes through + * the encoder. A direct string equality check against the column only works while the + * credential is held in the clear -- which is the weakness this level is named for. + */ if (userOpt.isPresent() && password != null - && password.equals(userOpt.get().getPassword())) { + && passwordEncoder.matches(password, userOpt.get().getPassword())) { return AuthResult.success(userOpt.get()); } return AuthResult.failure("Invalid credentials"); @@ -78,9 +86,13 @@ public AuthResult authenticate(String username, String password, int level) { return authenticateInternal(username, password, level, false); } - /** Authentication method that intentionally exposes username enumeration behavior. */ + /** + * Previously distinguished "User not found" from "Invalid password", which let an attacker + * confirm valid usernames one request at a time and build a target list before ever guessing a + * password. Both outcomes now report the same generic failure. + */ public AuthResult authenticateWithEnumeration(String username, String password, int level) { - return authenticateInternal(username, password, level, true); + return authenticateInternal(username, password, level, false); } private AuthResult authenticateInternal( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java index 6ced2e57d..944e0f7e6 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java @@ -141,9 +141,7 @@ public ResponseEntity> level3Plaintext( return response(result.getErrorMessage(), false); } // Exposure of plaintext password - Map profile = buildProfile(result.getUser()); - profile.put("passwordInDB", result.getUser().getPassword()); - return response(profile, true); + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 4 — MD5 @@ -397,10 +395,7 @@ public ResponseEntity> level10LowIterat if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - Map profile = buildProfile(result.getUser()); - profile.put("passwordHash", result.getUser().getPassword()); - profile.put("algorithm", "BCrypt (Cost: 4)"); - return response(profile, true); + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Helpers 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..c5356fbca 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); @@ -106,7 +106,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { String responseContent = buildLevel2Response(banner); return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildRouteAndBannerCacheKey(request, banner), responseContent, resolvePublicCacheControl(browserCache), true); @@ -148,11 +148,13 @@ public ResponseEntity> getVulnerablePay boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel4Response(request); + // The dashboard is derived from the caller's own cookie, so it must never be written to a + // shared cache -- whoever asked next would be served the previous user's profile. return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildPrivateResponseKey(request), responseContent, - resolvePublicCacheControl(browserCache), - true); + CACHE_CONTROL_PRIVATE_NO_STORE, + false); } @AttackVector( @@ -175,32 +177,37 @@ public ResponseEntity> getSecurePayload } private String buildLevel1Response(String banner) { - String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); + String safeBanner = + StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_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.

" + + "

The banner parameter is escaped before it is reflected and forms part of the cache key.

" + "
"; } private String buildLevel2Response(String banner) { - String filteredBanner = applyNaiveBannerFilter(banner); + // Escaping replaces the tag stripping filter rather than supplementing it: a blocklist that + // removes ", postCaptor.getValue().getContent()); - // Assert on the content of the response - assertEquals("
>alert('XSS')
", response.getBody()); + // Assert on the content of the response (HTML escaped) + assertEquals( + "
<script>alert('XSS')</script>
", + response.getBody()); // Assert on the HTTP response status code assertEquals(HttpStatus.OK, response.getStatusCode()); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerabilityTest.java index 71c1eeb08..eb455e647 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerabilityTest.java @@ -230,10 +230,13 @@ public void testLevel2_BlocksGeneralEntity() throws Exception { // 3. Assertions assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - // Since external-general-entities is FALSE, &xxe; will not expand to the file content - assertThat(response.getBody().getContent().getName()) - .as("General entities should be blocked in Level 2") - .isNotEqualTo("/etc/passwd"); + // The DOCTYPE declaration is now refused outright rather than parsed with its entity + // expansion turned off, so the document is rejected and no book is returned at all -- + // there is no parsed content left to compare against the file path. + assertThat(response.getBody().getContent()) + .as("A document declaring a DOCTYPE should be rejected in Level 2") + .isNull(); + assertThat(response.getBody().getIsValid()).isFalse(); } @Test