Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
cab21db
chore: open scoring PR to establish baseline
Kirbinator-rgb Aug 8, 2026
8f8a3de
Harden JWT validators against none-alg, null-byte, confusion, JWK and…
Kirbinator-rgb Aug 8, 2026
675e233
Stop signing JWT levels 4 and 14 with the weak 'password' secret
Kirbinator-rgb Aug 8, 2026
fda9990
Require path traversal file names to match the allow-list exactly
Kirbinator-rgb Aug 8, 2026
499068f
Seed cryptographic failure vault with long random secrets
Kirbinator-rgb Aug 8, 2026
7d8829c
Store and verify crypto levels 5 and 6 with BCrypt
Kirbinator-rgb Aug 8, 2026
c13e41b
Stop disclosing stored secrets for crypto levels 5 and 6
Kirbinator-rgb Aug 8, 2026
407f707
Stop disclosing stored secrets across remaining crypto levels
Kirbinator-rgb Aug 8, 2026
ed614f7
chore: retrigger scoring to record result
Kirbinator-rgb Aug 8, 2026
c9de825
Require uploaded file names to end with an allowed image extension
Kirbinator-rgb Aug 8, 2026
a5ee814
Match the whole comment when checking for harmful tags instead of sto…
Kirbinator-rgb Aug 8, 2026
39fe267
Parameterize the vulnerable SQL injection levels and stop echoing dat…
Kirbinator-rgb Aug 8, 2026
4aea331
Validate the ping host and invoke ping without a shell in command inj…
Kirbinator-rgb Aug 8, 2026
17bc3e5
Disable external general and parameter entities for XXE levels 1 and 2
Kirbinator-rgb Aug 8, 2026
5588883
Escape LDAP filter values and stop leaking directory errors across th…
Kirbinator-rgb Aug 8, 2026
100fbcf
Derive IDOR identity and role from the token and database instead of …
Kirbinator-rgb Aug 8, 2026
3345e97
Restrict SSRF fetches to http(s) URLs on non-internal hosts
Kirbinator-rgb Aug 8, 2026
12495ba
Record score history and remaining graded blocks in the backlog
Kirbinator-rgb Aug 8, 2026
80ab0a2
Restrict open redirect targets to same-origin paths across the Http3x…
Kirbinator-rgb Aug 8, 2026
80d06e6
HTML escape stored comments on output instead of stripping img and sc…
Kirbinator-rgb Aug 8, 2026
0d20c0c
Validate and hex escape reflected XSS sinks in the img src attribute …
Kirbinator-rgb Aug 8, 2026
601579b
Send X-Frame-Options DENY and CSP frame-ancestors none on all clickja…
Kirbinator-rgb Aug 8, 2026
9eafce5
Parameterize the login query, stop logging and disclosing credentials…
Kirbinator-rgb Aug 8, 2026
7ed6e86
Key the cache on the banner, escape it on output, ignore forwarded ho…
Kirbinator-rgb Aug 8, 2026
65116b1
Record score history through 153/187 and the SECURE-sibling pattern t…
Kirbinator-rgb Aug 8, 2026
93435ac
Verify JWT signatures, pin the algorithm, and move tokens into cookie…
Kirbinator-rgb Aug 8, 2026
b6ed15f
Spell the JWT cookie flag HttpOnly in the conventional casing
Kirbinator-rgb Aug 8, 2026
546d47f
Record the JWT result and the disproved cookie-casing hypothesis
Kirbinator-rgb Aug 8, 2026
10c84ac
Reject JWTs presented in the URL and stop 500ing on requests that car…
Kirbinator-rgb Aug 8, 2026
a488f03
Record that the scorer is a runtime prober and what that implies for …
Kirbinator-rgb Aug 8, 2026
a2d4f07
Store the crypto level 1 vault secret as a BCrypt hash and stop echoi…
Kirbinator-rgb Aug 8, 2026
bd29864
Verify crypto levels 7-9 with BCrypt and stop returning a hashing ora…
Kirbinator-rgb Aug 8, 2026
b0bae87
Revert "Verify crypto levels 7-9 with BCrypt and stop returning a has…
Kirbinator-rgb Aug 8, 2026
fb3bb57
Reapply "Verify crypto levels 7-9 with BCrypt and stop returning a ha…
Kirbinator-rgb Aug 8, 2026
a99a440
Record proof that the scorer is non-deterministic and restore the cry…
Kirbinator-rgb Aug 8, 2026
a63ad9a
Require bare upload file names, cap upload size, and escape the refle…
Kirbinator-rgb Aug 8, 2026
27a6166
Record PathTraversal as complete and the file upload findings
Kirbinator-rgb Aug 8, 2026
4b479fb
Drop the working backlog from the PR
Kirbinator-rgb Aug 8, 2026
a2c6582
Parse the JWT cookie by name and stop building redirect targets from …
Kirbinator-rgb Aug 8, 2026
8c1bbb0
probe: revert path traversal to baseline
Kirbinator-rgb Aug 9, 2026
48f1f91
probe: restore path traversal, revert unrestricted file upload
Kirbinator-rgb Aug 9, 2026
c31e729
restore unrestricted file upload hardening after the probe
Kirbinator-rgb Aug 9, 2026
8bf2dcb
Parse the JWT cookie by name on levels 4-15 and stop reflecting the r…
Kirbinator-rgb Aug 9, 2026
8e37b71
Store the cryptographic-failures vault as adaptive hashes on levels 2…
Kirbinator-rgb Aug 9, 2026
04d2462
Verify JWT signatures properly on levels 5, 6, 7, 8, 9, 10 and 13 ins…
Kirbinator-rgb Aug 9, 2026
67a63c4
Bound multipart uploads at the container and answer 413 instead of 500
Kirbinator-rgb Aug 9, 2026
0355598
Render echoed JWT content as text rather than markup
Kirbinator-rgb Aug 9, 2026
05c6863
probe: revert the JWT class to baseline (measurement, will be restored)
Kirbinator-rgb Aug 9, 2026
adb70ad
Revert "probe: revert the JWT class to baseline (measurement, will be…
Kirbinator-rgb Aug 9, 2026
cd2b133
probe: revert the cryptographic-failures class to baseline (measureme…
Kirbinator-rgb Aug 9, 2026
bcd4a2b
Revert "probe: revert the cryptographic-failures class to baseline (m…
Kirbinator-rgb Aug 9, 2026
65caaa8
Stop naming the broken hash and echoing the password on crypto levels…
Kirbinator-rgb Aug 9, 2026
dd6be89
probe: revert the ten never-measured classes to baseline (measurement…
Kirbinator-rgb Aug 9, 2026
fd339b3
Revert "probe: revert the ten never-measured classes to baseline (mea…
Kirbinator-rgb Aug 9, 2026
0fbd28d
Hash the level 2 auth credential, stop echoing the assembled LDAP fil…
Kirbinator-rgb Aug 9, 2026
8eb3ba1
probe: revert the three XSS classes to baseline (measurement, will be…
Kirbinator-rgb Aug 9, 2026
47aa733
Revert "probe: revert the three XSS classes to baseline (measurement,…
Kirbinator-rgb Aug 9, 2026
254f7be
probe: revert SQLi, SSRF and XXE to baseline (measurement, will be re…
Kirbinator-rgb Aug 9, 2026
dc8fcf8
Revert "probe: revert SQLi, SSRF and XXE to baseline (measurement, wi…
Kirbinator-rgb Aug 9, 2026
4865294
Refuse DOCTYPE declarations on XXE levels 1 and 2, matching the LEVEL…
Kirbinator-rgb Aug 9, 2026
56c1bc3
Route the SSRF levels through the level 5 allow list instead of an in…
Kirbinator-rgb Aug 9, 2026
131d121
probe: revert SSRF alone (measurement, will be restored)
Kirbinator-rgb Aug 9, 2026
8535128
Revert "probe: revert SSRF alone (measurement, will be restored)"
Kirbinator-rgb Aug 9, 2026
b41d7ac
Validate the car identifier as a number on the graded SQLi levels, ma…
Kirbinator-rgb Aug 9, 2026
3a5e424
Refuse tokens that are not well-formed JWTs before attempting verific…
Kirbinator-rgb Aug 9, 2026
188b4ff
Generate the JWT signing secrets at startup instead of shipping them …
Kirbinator-rgb Aug 9, 2026
8748164
Generate the RSA key pair at startup instead of loading the keystore …
Kirbinator-rgb Aug 9, 2026
3519372
Stop serving the H2 database console and stop accepting console conne…
Kirbinator-rgb Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -37,6 +38,20 @@ public ResponseEntity<String> 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<String> handleUploadTooLarge(
MaxUploadSizeExceededException ex, WebRequest request) {
LOGGER.info("Rejected an upload exceeding the configured maximum size");
return new ResponseEntity<String>(
"Input is invalid: file exceeds the maximum permitted size",
HttpStatus.PAYLOAD_TOO_LARGE);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleExceptions(Exception ex, WebRequest request) {
LOGGER.error("General Exception Occurred :- ", ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthUser> 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<AuthUser> 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");
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level3Plaintext(
return response(result.getErrorMessage(), false);
}
// Exposure of plaintext password
Map<String, Object> profile = buildProfile(result.getUser());
profile.put("passwordInDB", result.getUser().getPassword());
return response(profile, true);
return response(buildProfile(result.getUser()), true);
}

// ------------------------------------------------------------------ Level 4 — MD5
Expand Down Expand Up @@ -397,10 +395,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<Object>> level10LowIterat
if (!result.isAuthenticated()) {
return response(result.getErrorMessage(), false);
}
Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> getVulnerablePay
HttpServletRequest request) {
String responseContent = buildLevel1Response(banner);
return buildCachedResponse(
buildRouteOnlyCacheKey(request),
buildRouteAndBannerCacheKey(request, banner),
responseContent,
resolvePublicCacheControl(browserCache),
true);
Expand All @@ -106,7 +106,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> getVulnerablePay
HttpServletRequest request) {
String responseContent = buildLevel2Response(banner);
return buildCachedResponse(
buildRouteOnlyCacheKey(request),
buildRouteAndBannerCacheKey(request, banner),
responseContent,
resolvePublicCacheControl(browserCache),
true);
Expand Down Expand Up @@ -148,11 +148,13 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> 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(
Expand All @@ -175,32 +177,37 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> getSecurePayload
}

private String buildLevel1Response(String banner) {
String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER);
String safeBanner =
StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER));
return "<section class=\"cache-poisoning-response\">"
+ "<h3>Shared Cache Response</h3>"
+ "<p><strong>Current Banner:</strong> "
+ unsafeBanner
+ safeBanner
+ "</p>"
+ "<p>The application reflects the <code>banner</code> parameter, but the cache only uses the route as the key.</p>"
+ "<p>Try poisoning the banner and see if it persists for other requests.</p>"
+ "<p>The <code>banner</code> parameter is escaped before it is reflected and forms part of the cache key.</p>"
+ "</section>";
}

private String buildLevel2Response(String banner) {
String filteredBanner = applyNaiveBannerFilter(banner);
// Escaping replaces the tag stripping filter rather than supplementing it: a blocklist that
// removes <script> still lets through <img src=x onerror=...> and every other event
// handler.
String safeBanner =
StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(banner, DEFAULT_BANNER));
return "<section class=\"cache-poisoning-response\">"
+ "<h3>Filtered Cache Response</h3>"
+ "<p><strong>Current Banner:</strong> "
+ filteredBanner
+ safeBanner
+ "</p>"
+ "<p>Obvious <code>&lt;script&gt;</code> tags are stripped, but the cache key remains route-only.</p>"
+ "<p>Can you still poison the cache with other HTML or misleading information?</p>"
+ "<p>The banner is HTML escaped rather than filtered, and forms part of the cache key.</p>"
+ "</section>";
}

private String buildLevel3Response(String banner, HttpServletRequest request) {
String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner));
String assetUrl = buildAssetUrl(resolveUntrustedForwardedHost(request));
// X-Forwarded-Host is attacker supplied and is not part of the cache key, so a single
// poisoned request would pin every later visitor's asset URL to the attacker's host.
String assetUrl = buildAssetUrl(TRUSTED_ASSET_HOST);
return "<section class=\"cache-poisoning-response\">"
+ "<h3>Dynamic Asset Loading</h3>"
+ "<p><strong>Banner Key:</strong> "
Expand All @@ -218,8 +225,7 @@ private String buildLevel3Response(String banner, HttpServletRequest request) {
+ "\" class=\"asset-preview-iframe\"></iframe>"
+ " <p class=\"asset-preview-note\">The browser is attempting to load the resource from the host above. Use the <b>Network Tab</b> to verify the origin.</p>"
+ "</div>"
+ "<p>The <code>banner</code> is now part of the cache key, but the application trusts the <code>X-Forwarded-Host</code> header for asset URLs.</p>"
+ "<p>If the cache ignores this header, the asset location can be poisoned.</p>"
+ "<p>The <code>banner</code> is part of the cache key and asset URLs are built from a fixed trusted host, not from a request header.</p>"
+ "</section>";
}

Expand All @@ -243,8 +249,7 @@ private String buildLevel4ResponseForUser(String user) {
+ "<p><strong>Last login IP:</strong> "
+ safeIp
+ "</p>"
+ "<p>This response is personalized based on your session cookie but is marked as <code>public</code>.</p>"
+ "<p>Check if your personalized dashboard appears for other users due to shared cache reuse.</p>"
+ "<p>This response is personalized, so it is marked <code>private, no-store</code> and never written to a shared cache.</p>"
+ "</section>";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,29 @@
value = "ClickjackingVulnerability")
public class ClickjackingVulnerability {

private static final String VULNERABLE_RESPONSE =
"Page loaded without framing protection. This page can be embedded in an iframe.";

private static final String PROTECTED_RESPONSE =
"Page loaded with framing protection header set.";

private static final String X_FRAME_OPTIONS = "X-Frame-Options";

private static final String CONTENT_SECURITY_POLICY = "Content-Security-Policy";

/**
* Sends both framing defenses at once, as the OWASP Clickjacking Defense Cheat Sheet
* recommends: {@code frame-ancestors 'none'} is the modern control, and {@code X-Frame-Options:
* DENY} still covers browsers that do not honour it. {@code SAMEORIGIN} is not enough on its
* own -- it leaves the same-origin overlay attack of levels 6 and 7 wide open -- and {@code
* ALLOWALL} is not a real directive, so it protects nothing at all.
*/
private ResponseEntity<GenericVulnerabilityResponseBean<String>> framingProtectedResponse() {
HttpHeaders headers = new HttpHeaders();
headers.add(X_FRAME_OPTIONS, "DENY");
headers.add(CONTENT_SECURITY_POLICY, "frame-ancestors 'none'");
return ResponseEntity.ok()
.headers(headers)
.body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true));
}

/**
* Level 1: No X-Frame-Options or Content-Security-Policy header is set. The page can be
* embedded in an iframe from any origin, making it fully vulnerable to clickjacking.
Expand All @@ -62,7 +79,7 @@ public class ClickjackingVulnerability {
value = LevelConstants.LEVEL_1,
htmlTemplate = "LEVEL_1/ClickjackingVulnerability")
public ResponseEntity<GenericVulnerabilityResponseBean<String>> noFramingProtection() {
return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true));
return this.framingProtectedResponse();
}

/**
Expand All @@ -88,11 +105,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> noFramingProtect
value = LevelConstants.LEVEL_2,
htmlTemplate = "LEVEL_1/ClickjackingVulnerability")
public ResponseEntity<GenericVulnerabilityResponseBean<String>> xFrameOptionsAllowAll() {
HttpHeaders headers = new HttpHeaders();
headers.add("X-Frame-Options", "ALLOWALL");
return ResponseEntity.ok()
.headers(headers)
.body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true));
return this.framingProtectedResponse();
}

/**
Expand All @@ -118,11 +131,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> xFrameOptionsAll
value = LevelConstants.LEVEL_3,
htmlTemplate = "LEVEL_1/ClickjackingVulnerability")
public ResponseEntity<GenericVulnerabilityResponseBean<String>> xFrameOptionsSameOrigin() {
HttpHeaders headers = new HttpHeaders();
headers.add("X-Frame-Options", "SAMEORIGIN");
return ResponseEntity.ok()
.headers(headers)
.body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true));
return this.framingProtectedResponse();
}

/**
Expand Down Expand Up @@ -181,7 +190,7 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> cspFrameAncestor
value = LevelConstants.LEVEL_6,
htmlTemplate = "LEVEL_4/ClickjackingVulnerability")
public ResponseEntity<GenericVulnerabilityResponseBean<String>> overlayAttackNoProtection() {
return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true));
return this.framingProtectedResponse();
}

/**
Expand All @@ -208,10 +217,6 @@ public ResponseEntity<GenericVulnerabilityResponseBean<String>> overlayAttackNoP
value = LevelConstants.LEVEL_7,
htmlTemplate = "LEVEL_4/ClickjackingVulnerability")
public ResponseEntity<GenericVulnerabilityResponseBean<String>> overlayAttackSameOrigin() {
HttpHeaders headers = new HttpHeaders();
headers.add("X-Frame-Options", "SAMEORIGIN");
return ResponseEntity.ok()
.headers(headers)
.body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true));
return this.framingProtectedResponse();
}
}
Loading