verify(
+ String message,
+ String signature,
+ long matchingByteDelayMillis,
+ int maximumJitterMillis) {
+ if (!state.tryAcquireRequestSlot()) {
+ return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
+ .contentType(MediaType.TEXT_PLAIN)
+ .body("Too many concurrent requests");
+ }
+
+ try {
+ byte[] supplied = TimingAttackSupport.parseTag(signature, TimingAttackSessionState.TAG_LENGTH);
+ boolean valid =
+ supplied != null
+ && validator.matches(
+ state.tagFor(message),
+ supplied,
+ matchingByteDelayMillis,
+ maximumJitterMillis);
+ return ResponseEntity.ok(valid ? VALID : INVALID);
+ } finally {
+ state.releaseRequestSlot();
+ }
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java
new file mode 100644
index 00000000000..6a447e14a4b
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.concurrent.Semaphore;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.annotation.SessionScope;
+
+@Component
+@SessionScope
+public class TimingAttackSessionState implements Serializable {
+
+ @Serial private static final long serialVersionUID = 1L;
+
+ static final String KNOWN_MESSAGE = "WebGoat";
+ static final String NOISY_MESSAGE = "WebGoat with noise";
+ static final int TAG_LENGTH = 4;
+ static final int MAX_CONCURRENT_REQUESTS = 4;
+
+ private final byte[] secret;
+ private final Semaphore requestSlots = new Semaphore(MAX_CONCURRENT_REQUESTS);
+
+ public TimingAttackSessionState() {
+ secret = new byte[32];
+ new SecureRandom().nextBytes(secret);
+ }
+
+ byte[] tagFor(String message) {
+ try {
+ Mac hmac = Mac.getInstance("HmacSHA256");
+ hmac.init(new SecretKeySpec(secret, "HmacSHA256"));
+ return Arrays.copyOf(hmac.doFinal(message.getBytes(StandardCharsets.UTF_8)), TAG_LENGTH);
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("HmacSHA256 is not available", e);
+ }
+ }
+
+ boolean tryAcquireRequestSlot() {
+ return requestSlots.tryAcquire();
+ }
+
+ void releaseRequestSlot() {
+ requestSlots.release();
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java
new file mode 100644
index 00000000000..a3306876430
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import java.util.HexFormat;
+
+final class TimingAttackSupport {
+
+ private TimingAttackSupport() {}
+
+ static byte[] parseTag(String value, int expectedBytes) {
+ if (value == null || value.length() != expectedBytes * 2) {
+ return null;
+ }
+ try {
+ return HexFormat.of().parseHex(value);
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java
new file mode 100644
index 00000000000..9f338434873
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed;
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success;
+
+import java.security.MessageDigest;
+import org.owasp.webgoat.container.assignments.AssignmentEndpoint;
+import org.owasp.webgoat.container.assignments.AssignmentHints;
+import org.owasp.webgoat.container.assignments.AttackResult;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@AssignmentHints({
+ "timing-attacks.hints.keep-prefix",
+ "timing-attacks.hints.repeat"
+})
+public class TimingAttackTagAssignment implements AssignmentEndpoint {
+
+ private final TimingAttackSessionState state;
+
+ public TimingAttackTagAssignment(TimingAttackSessionState state) {
+ this.state = state;
+ }
+
+ @PostMapping("/crypto/timing/tag")
+ @ResponseBody
+ public AttackResult submit(@RequestParam(required = false) String signature) {
+ byte[] supplied = TimingAttackSupport.parseTag(signature, TimingAttackSessionState.TAG_LENGTH);
+ byte[] expected = state.tagFor(TimingAttackSessionState.KNOWN_MESSAGE);
+ if (supplied != null && MessageDigest.isEqual(expected, supplied)) {
+ return success(this).feedback("timing-attacks.tag.success").build();
+ }
+ return failed(this).feedback("timing-attacks.try-again").build();
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java
new file mode 100644
index 00000000000..b18ae19a6b5
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import org.owasp.webgoat.container.lessons.Category;
+import org.owasp.webgoat.container.lessons.Lesson;
+import org.springframework.stereotype.Component;
+
+@Component
+public class TimingAttacks extends Lesson {
+
+ @Override
+ public Category getDefaultCategory() {
+ return Category.A2;
+ }
+
+ @Override
+ public String getTitle() {
+ return "timing-attacks.title";
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java
new file mode 100644
index 00000000000..4adf0dc6b96
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.httpparameterpollution;
+
+import org.owasp.webgoat.container.lessons.Category;
+import org.owasp.webgoat.container.lessons.Lesson;
+import org.springframework.stereotype.Component;
+
+@Component
+public class HttpParameterPollution extends Lesson {
+
+ @Override
+ public Category getDefaultCategory() {
+ return Category.A7;
+ }
+
+ @Override
+ public String getTitle() {
+ return "http-parameter-pollution.title";
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java
new file mode 100644
index 00000000000..16a268664b2
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.httpparameterpollution;
+
+final class ParameterPollutionMitigation {
+
+ private ParameterPollutionMitigation() {}
+
+ static String requireSingleValue(String[] values) {
+ if (values == null || values.length != 1) {
+ throw new IllegalArgumentException("Exactly one value is required");
+ }
+ return values[0];
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java
new file mode 100644
index 00000000000..3eda5526366
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.httpparameterpollution;
+
+import static org.apache.commons.text.StringEscapeUtils.escapeHtml4;
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed;
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Arrays;
+import org.owasp.webgoat.container.assignments.AssignmentEndpoint;
+import org.owasp.webgoat.container.assignments.AssignmentHints;
+import org.owasp.webgoat.container.assignments.AttackResult;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@AssignmentHints({
+ "http-parameter-pollution.hints.observation.1",
+ "http-parameter-pollution.hints.observation.2"
+})
+public class ParameterPollutionObservation implements AssignmentEndpoint {
+
+ @GetMapping("/HttpParameterPollution/parameters")
+ @ResponseBody
+ public AttackResult showParameters(HttpServletRequest request) {
+ String[] values = request.getParameterValues("name");
+ String firstValue = values == null || values.length == 0 ? "No value" : values[0];
+ String allValues = values == null ? "[]" : Arrays.toString(values);
+ int valueCount = values == null ? 0 : values.length;
+ String output =
+ "The request contains "
+ + valueCount
+ + " values named name.
"
+ + "A component that expects one value reads: "
+ + escapeHtml4(firstValue)
+ + "
A component that accepts every value reads: "
+ + escapeHtml4(allValues)
+ + "
";
+
+ boolean hasDifferentValues =
+ values != null
+ && values.length > 1
+ && Arrays.stream(values).skip(1).anyMatch(value -> !values[0].equals(value));
+
+ if (hasDifferentValues) {
+ return success(this)
+ .feedback("http-parameter-pollution.observation.success")
+ .output(output)
+ .build();
+ }
+ return failed(this)
+ .feedback("http-parameter-pollution.observation.try-again")
+ .output(output)
+ .build();
+ }
+}
diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java
new file mode 100644
index 00000000000..7ecffb1b6a2
--- /dev/null
+++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.httpparameterpollution;
+
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed;
+import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.owasp.webgoat.container.assignments.AssignmentEndpoint;
+import org.owasp.webgoat.container.assignments.AssignmentHints;
+import org.owasp.webgoat.container.assignments.AttackResult;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@AssignmentHints({
+ "http-parameter-pollution.hints.transfer.1",
+ "http-parameter-pollution.hints.transfer.2",
+ "http-parameter-pollution.hints.transfer.3",
+ "http-parameter-pollution.hints.transfer.4"
+})
+public class ParameterPollutionTransfer implements AssignmentEndpoint {
+
+ @PostMapping("/HttpParameterPollution/transfer")
+ @ResponseBody
+ public AttackResult transfer(HttpServletRequest request) {
+ String[] recipients = request.getParameterValues("recipient");
+ if (recipients == null || recipients.length == 0 || recipients[0].isBlank()) {
+ return failed(this)
+ .feedback("http-parameter-pollution.transfer.missing-recipient")
+ .build();
+ }
+
+ Integer amount = readAmount(request.getParameterValues("amount"));
+ if (amount == null) {
+ return failed(this).feedback("http-parameter-pollution.transfer.invalid-amount").build();
+ }
+
+ String validatedRecipient = recipients[0];
+ String actualRecipient = recipients[recipients.length - 1];
+
+ if (!"alice".equals(validatedRecipient)) {
+ return failed(this).feedback("http-parameter-pollution.transfer.unauthorized").build();
+ }
+ if ("attacker".equals(actualRecipient) && amount == 100) {
+ return success(this).feedback("http-parameter-pollution.transfer.success").build();
+ }
+ if ("alice".equals(actualRecipient) && amount == 100) {
+ return failed(this).feedback("http-parameter-pollution.transfer.allowed").build();
+ }
+ return failed(this).feedback("http-parameter-pollution.transfer.invalid-recipient").build();
+ }
+
+ private Integer readAmount(String[] amounts) {
+ if (amounts == null || amounts.length != 1) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(amounts[0]);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc
new file mode 100644
index 00000000000..5bbeb83019e
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc
@@ -0,0 +1,11 @@
+== Discover the side channel
+
+Send candidate tags to this endpoint:
+
+----
+GET /WebGoat/crypto/timing/verify?message=WebGoat&signature=00000000
+----
+
+Every invalid candidate returns the same status and the same `Invalid signature` body. The comparison still behaves differently. It checks bytes from left to right, stops at the first mismatch, and waits about 25 milliseconds after each matching byte.
+
+Change only the first byte, measure the complete request time, and repeat measurements before deciding which value is slower. Submit that byte as two hexadecimal characters.
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc
new file mode 100644
index 00000000000..b147a78cfa6
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc
@@ -0,0 +1,12 @@
+== Timing attacks
+
+A message authentication code, or MAC, lets a recipient check that a message came from someone who knows a shared secret and that the message was not changed. HMAC builds a MAC from a cryptographic hash function and a secret key.
+
+Knowing the message does not reveal its HMAC. In this lesson, the server calculates `HMAC-SHA256(secret, message)` correctly. The weakness appears later, when application code compares the expected tag with the supplied tag.
+
+[quote]
+Strong cryptography cannot protect you from information leaked by the code around it.
+
+The exercises use the known message `WebGoat`. They truncate the HMAC-SHA256 result to four bytes so the lab can finish in a reasonable time. A 32-bit authentication tag is not suitable for production use. You are not expected to brute-force the complete tag. Exploit the timing information instead.
+
+The server generates a different HMAC secret for each session and calculates every expected tag dynamically. Copying another learner's tag will not work.
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc
new file mode 100644
index 00000000000..78820b32d07
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc
@@ -0,0 +1,16 @@
+== Connection to JWT
+
+JWT tokens that use HS256 authenticate this signing input:
+
+----
+base64url(header) + "." + base64url(payload)
+----
+
+with HMAC-SHA256. The JWT signature is therefore an HMAC authentication value. A JWT verifier that compares it with a secret-dependent early exit can expose the same timing side channel.
+
+The attack in this lesson did not crack HMAC-SHA256. The server calculated a secure primitive correctly, then leaked information while checking its result. The existing JWT lesson covers token structure and JWT-specific attacks in more detail.
+
+[source]
+----
+Secure primitive + insecure implementation behavior = exploitable system
+----
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc
new file mode 100644
index 00000000000..4d4c05288c4
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc
@@ -0,0 +1,18 @@
+== Review the repair
+
+The vulnerable comparison has a secret-dependent early exit:
+
+[source,java]
+----
+for (int i = 0; i < expected.length; i++) {
+ if (expected[i] != provided[i]) {
+ return false;
+ }
+}
+----
+
+Adding random delay makes measurement more expensive, but repeated sampling can still reveal the signal. Converting the values to strings moves the same comparison mistake into another API.
+
+Choose the root cause and the repair that removes it. Application code should use a library comparison intended for authentication values instead of inventing its own constant-time loop.
+
+Java cannot promise identical CPU time under every JIT, cache, scheduler, runtime, and hardware condition. The practical rule is to avoid intentional secret-dependent early exits and use an appropriate library primitive such as `MessageDigest.isEqual(expected, provided)`.
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc
new file mode 100644
index 00000000000..7dea2c730fb
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc
@@ -0,0 +1,11 @@
+== Extract a noisy signal
+
+Real measurements vary because of scheduling, runtime activity, network latency, and other work on the host. This endpoint adds between 0 and 8 milliseconds of random jitter while reducing the matching-byte signal to about 2 milliseconds:
+
+----
+GET /WebGoat/crypto/timing/verify-noisy?message=WebGoat%20with%20noise&signature=00000000
+----
+
+This assignment uses the message `WebGoat with noise`, so the tag recovered in the previous assignment cannot be reused. One request is no longer useful evidence. Take several samples for each candidate and compare an aggregate such as the median, mean, or trimmed mean. No single statistical method is required.
+
+You can reduce the request count through progressive elimination. Measure every candidate a few times, retain the strongest group, take more samples for that group, and repeat until one candidate remains. This is a statistical attack against an implementation leak. It does not recover the HMAC secret or break SHA-256.
diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc
new file mode 100644
index 00000000000..05043995ec6
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc
@@ -0,0 +1,11 @@
+== Recover the tag
+
+The next endpoint reduces the delay to about 5 milliseconds per matching byte:
+
+----
+GET /WebGoat/crypto/timing/verify-tag?message=WebGoat&signature=00000000
+----
+
+Recover the four-byte tag one position at a time. Keep the known prefix fixed, try each possible value for the next byte, and pad the untested positions. Repeat measurements to reduce mistakes. The candidate whose requests consistently take longest is evidence for the next byte.
+
+The exercise supports scripts, intercepting proxies, command-line tools, and AI assistants. The objective is to explain why the measurements reveal a prefix, not to type hundreds of requests by hand.
diff --git a/src/main/resources/lessons/cryptography/html/TimingAttacks.html b/src/main/resources/lessons/cryptography/html/TimingAttacks.html
new file mode 100644
index 00000000000..c0ab0cc81d6
--- /dev/null
+++ b/src/main/resources/lessons/cryptography/html/TimingAttacks.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties b/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties
index 51f1f862163..d5ba18fda23 100644
--- a/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties
+++ b/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties
@@ -1,4 +1,23 @@
6.crypto.title=Crypto Basics
+timing-attacks.title=Timing Attacks
+
+timing-attacks.try-again=That value is not correct. Gather more timing samples and try again.
+timing-attacks.first-byte.success=Correct. The response timing revealed the first byte without breaking HMAC-SHA256.
+timing-attacks.tag.success=Correct. You reconstructed the short educational tag from the prefix-dependent delay.
+timing-attacks.noise.success=Correct. Repeated measurements extracted the signal despite the jitter.
+timing-attacks.mitigation.success=Correct. The early exit caused the leak, and MessageDigest.isEqual is the appropriate Java comparison primitive here.
+timing-attacks.mitigation.try-again=Review whether the proposed repair removes the secret-dependent early exit or merely makes measurement harder.
+
+timing-attacks.hints.behavior=Every invalid response looks the same, but check whether every request takes the same amount of time.
+timing-attacks.hints.measure=Measure the complete request duration and repeat each candidate more than once.
+timing-attacks.hints.first-byte=Change only the first byte. Keep all remaining bytes at a fixed padding value.
+timing-attacks.hints.keep-prefix=Once you identify a byte, keep that prefix fixed while testing the next position.
+timing-attacks.hints.repeat=One slow request may be an outlier. Compare repeated measurements for each candidate.
+timing-attacks.hints.noise=The random jitter can be larger than one byte of signal, so one sample cannot identify a candidate.
+timing-attacks.hints.statistics=Compare candidate distributions with an aggregate such as the median or a trimmed mean.
+timing-attacks.hints.elimination=Sample every candidate a few times, keep the strongest group, and spend more samples only on that group.
+timing-attacks.hints.mitigation=Random sleep adds noise but does not remove the relationship between a matching prefix and execution time.
+timing-attacks.hints.primitive=The HMAC primitive remains secure. Look at how the application compares the calculated and supplied tags.
crypto-encoding.empty=Try again, did you decode it properly?
crypto-encoding.success=Congratulations. That was easy, right?
diff --git a/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css b/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css
new file mode 100644
index 00000000000..522461f605f
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css
@@ -0,0 +1,22 @@
+.http-parameter-fields {
+ display: grid;
+ grid-template-columns: max-content 20rem;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.http-parameter-fields label {
+ margin: 0;
+}
+
+.http-parameter-fields input {
+ box-sizing: border-box;
+ width: 100%;
+}
+
+@media (max-width: 576px) {
+ .http-parameter-fields {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc
new file mode 100644
index 00000000000..377a25472ce
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc
@@ -0,0 +1,16 @@
+== Introduction: one name, multiple values
+
+HTTP Parameter Pollution (HPP) occurs when the same parameter name appears more than once in an HTTP request.
+
+Consider this request:
+
+[source,text]
+----
+?color=red&color=blue
+----
+
+What is the value of `color`? The answer depends on how the receiving application interprets the request.
+
+Different components can interpret the same request differently. A proxy might inspect one value while the application uses another. Frameworks, gateways, and custom validation code can also disagree.
+
+The security problem begins when one interpretation controls a security decision and another controls the operation.
diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc
new file mode 100644
index 00000000000..085551667a8
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc
@@ -0,0 +1,20 @@
+== Observe the parser
+
+Different parts of an application may interpret duplicate parameters in different ways.
+
+For example, a component might use the first value, the last value, or the complete list.
+
+=== Assignment
+
+The form below has two inputs named `name`. The browser sends both values in one query string:
+
+[source,text]
+----
+?name=WebGoat&name=WebGoat
+----
+
+Submit the form once and compare what a component that expects one value sees with what a component that accepts several values sees.
+
+Then change one input so the two values differ and submit the form again.
+
+The endpoint reads the complete value array. It deliberately displays the first value beside the full array, so the result does not depend on implicit framework behavior.
diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc
new file mode 100644
index 00000000000..9af0a6f8d74
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc
@@ -0,0 +1,13 @@
+== Exploit: transfer validation bypass
+
+This exercise contains a fictional transfer function. You may transfer 100 credits to `alice`. The recipient `attacker` is not approved.
+
+A direct transfer to `attacker` fails. A normal transfer to `alice` succeeds, but it does not complete the assignment.
+
+The validation code and transfer code do not agree on the meaning of the request.
+
+=== Assignment
+
+Transfer exactly 100 credits to `attacker` without failing the recipient validation. Use what you observed on the previous page.
+
+The form creates an ordinary transfer request with one recipient. You may use browser developer tools or an intercepting proxy to inspect and modify that request.
diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc
new file mode 100644
index 00000000000..987ed223446
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc
@@ -0,0 +1,27 @@
+== Mitigation
+
+Define how many values each parameter accepts. Then make validation and business logic use the same canonical representation.
+
+For a single-valued recipient, reject requests that contain anything other than one value:
+
+[source,java]
+----
+values = allValues("recipient");
+if (values == null || values.size() != 1) {
+ reject("Exactly one recipient is required");
+}
+
+recipient = values.get(0);
+validateAllowedRecipient(recipient);
+transfer(recipient, amount);
+----
+
+If an input is intentionally multi-valued, model it as a collection and validate every value. Choosing the first or last value is not a general defense. The application must define the parameter contract and use it consistently.
+
+=== Spring MVC handling
+
+Spring MVC binds query parameters and form data with `@RequestParam`. Declaring the controller argument as an array or `List` resolves all values that use the same parameter name. A `MultiValueMap` can retain all values for every parameter.
+
+Do not rely on scalar binding as a duplicate-parameter policy. For a security-sensitive, single-valued field, read the complete value set with `HttpServletRequest.getParameterValues()`, reject unexpected multiplicity, and pass the one accepted value to both validation and business logic.
+
+This lesson also reads the complete value array. Its vulnerable endpoint deliberately selects the first recipient for validation and the last recipient for execution. The mismatch is explicit, so the exercise does not depend on undocumented Spring or servlet-container behavior.
diff --git a/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html b/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html
new file mode 100644
index 00000000000..d0f4ebbdea6
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties b/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties
new file mode 100644
index 00000000000..6caac28efa2
--- /dev/null
+++ b/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties
@@ -0,0 +1,17 @@
+http-parameter-pollution.title=HTTP Parameter Pollution
+
+http-parameter-pollution.hints.observation.1=Can one parameter name occur more than once in a query string?
+http-parameter-pollution.hints.observation.2=Try name=WebGoat&name=OWASP.
+http-parameter-pollution.observation.success=The two components received the same request but used its values differently.
+http-parameter-pollution.observation.try-again=Both components currently agree. Change one name value and submit the form again.
+
+http-parameter-pollution.hints.transfer.1=Submit one transfer to alice and one to attacker. Compare the feedback from both requests.
+http-parameter-pollution.hints.transfer.2=The form has one recipient input, but the HTTP request can contain recipient more than once. Intercept the request or edit it with your browser tools.
+http-parameter-pollution.hints.transfer.3=Validation checks the first recipient value, while the transfer uses the last value.
+http-parameter-pollution.hints.transfer.4=Send alice as the first recipient and attacker as the last recipient. Keep a single amount with the value 100.
+http-parameter-pollution.transfer.missing-recipient=The recipient parameter is missing.
+http-parameter-pollution.transfer.invalid-amount=Submit exactly one numeric amount with a value of 100.
+http-parameter-pollution.transfer.invalid-recipient=The transfer did not send 100 credits to the attacker.
+http-parameter-pollution.transfer.unauthorized=The validated recipient is not approved.
+http-parameter-pollution.transfer.allowed=The transfer to alice succeeded, but the exploit assignment is not complete.
+http-parameter-pollution.transfer.success=The validation passed for alice, but the transfer sent 100 credits to the attacker.
diff --git a/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java
new file mode 100644
index 00000000000..4e0d5c90b05
--- /dev/null
+++ b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class InsecureHmacValidatorTest {
+
+ @Test
+ void delayDependsOnTheNumberOfMatchingPrefixBytes() {
+ List delays = new ArrayList<>();
+ var validator = new InsecureHmacValidator(delays::add, ignored -> 0);
+ byte[] expected = {0x01, 0x02, 0x03, 0x04};
+
+ assertThat(validator.matches(expected, new byte[] {0x01, 0x02, 0x7f, 0x00}, 5, 0))
+ .isFalse();
+
+ assertThat(delays)
+ .containsExactly(TimeUnit.MILLISECONDS.toNanos(5), TimeUnit.MILLISECONDS.toNanos(5));
+ }
+
+ @Test
+ void firstByteMismatchHasNoMatchingByteDelay() {
+ List delays = new ArrayList<>();
+ var validator = new InsecureHmacValidator(delays::add, ignored -> 0);
+
+ assertThat(
+ validator.matches(
+ new byte[] {0x01, 0x02, 0x03, 0x04},
+ new byte[] {0x7f, 0x02, 0x03, 0x04},
+ 25,
+ 0))
+ .isFalse();
+ assertThat(delays).isEmpty();
+ }
+
+ @Test
+ void jitterIsIndependentOfTheMatchingPrefix() {
+ List delays = new ArrayList<>();
+ var validator = new InsecureHmacValidator(delays::add, ignored -> 7);
+
+ assertThat(
+ validator.matches(
+ new byte[] {0x01, 0x02, 0x03, 0x04},
+ new byte[] {0x7f, 0x02, 0x03, 0x04},
+ 2,
+ 8))
+ .isFalse();
+ assertThat(delays).containsExactly(TimeUnit.MILLISECONDS.toNanos(7));
+ }
+
+ @Test
+ void equalTagsMatchAfterEveryByteIsChecked() {
+ List delays = new ArrayList<>();
+ var validator = new InsecureHmacValidator(delays::add, ignored -> 0);
+ byte[] tag = {0x01, 0x02, 0x03, 0x04};
+
+ assertThat(validator.matches(tag, tag.clone(), 2, 0)).isTrue();
+ assertThat(delays).hasSize(tag.length);
+ }
+}
diff --git a/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java
new file mode 100644
index 00000000000..66b986256e7
--- /dev/null
+++ b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java
@@ -0,0 +1,197 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.cryptography.timing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Collections;
+import java.util.HexFormat;
+import org.junit.jupiter.api.Test;
+import org.owasp.webgoat.container.lessons.LessonName;
+import org.owasp.webgoat.container.plugins.LessonTest;
+import org.owasp.webgoat.container.session.Course;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.mock.web.MockHttpSession;
+
+class TimingAttacksTest extends LessonTest {
+
+ @Autowired private Course course;
+
+ @Test
+ void assignmentsAreAttachedToTheTimingLesson() {
+ var timingLesson = course.getLessonByName(new LessonName("TimingAttacks"));
+
+ assertThat(timingLesson.getAssignments())
+ .extracting(assignment -> assignment.getName())
+ .containsExactlyInAnyOrder(
+ "TimingAttackFirstByteAssignment",
+ "TimingAttackTagAssignment",
+ "TimingAttackNoiseAssignment",
+ "TimingAttackMitigationAssignment");
+ }
+
+ @Test
+ void lessonPageRenders() throws Exception {
+ mockMvc
+ .perform(get("/TimingAttacks.lesson"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("Discover the side channel")))
+ .andExpect(content().string(containsString("MessageDigest.isEqual")))
+ .andExpect(content().string(containsString("Connection to JWT")));
+ }
+
+ @Test
+ void invalidOracleResponsesAreIndistinguishable() throws Exception {
+ MockHttpSession session = new MockHttpSession();
+ TimingAttackSessionState state = initializeState(session);
+ String incorrectTag = incorrectTagFor(state, TimingAttackSessionState.KNOWN_MESSAGE);
+
+ mockMvc
+ .perform(
+ get("/crypto/timing/verify")
+ .session(session)
+ .param("message", TimingAttackSessionState.KNOWN_MESSAGE)
+ .param("signature", "not-hex!"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(TimingAttackOracle.INVALID));
+
+ mockMvc
+ .perform(
+ get("/crypto/timing/verify")
+ .session(session)
+ .param("message", TimingAttackSessionState.KNOWN_MESSAGE)
+ .param("signature", incorrectTag))
+ .andExpect(status().isOk())
+ .andExpect(content().string(TimingAttackOracle.INVALID));
+ }
+
+ @Test
+ void oracleAcceptsTheDynamicSessionTag() throws Exception {
+ MockHttpSession session = new MockHttpSession();
+ String tag = tagFor(initializeState(session), TimingAttackSessionState.KNOWN_MESSAGE);
+ mockMvc
+ .perform(
+ get("/crypto/timing/verify-tag")
+ .session(session)
+ .param("message", TimingAttackSessionState.KNOWN_MESSAGE)
+ .param("signature", tag))
+ .andExpect(status().isOk())
+ .andExpect(content().string(TimingAttackOracle.VALID));
+ }
+
+ @Test
+ void firstByteAssignmentChecksTheCurrentSession() throws Exception {
+ MockHttpSession session = new MockHttpSession();
+ String tag = tagFor(initializeState(session), TimingAttackSessionState.KNOWN_MESSAGE);
+
+ mockMvc
+ .perform(post("/crypto/timing/first-byte").session(session).param("firstByte", "zz"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+
+ mockMvc
+ .perform(
+ post("/crypto/timing/first-byte")
+ .session(session)
+ .param("firstByte", tag.substring(0, 2)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+ }
+
+ @Test
+ void completeTagAssignmentsUseTheCorrectPerSessionTags() throws Exception {
+ MockHttpSession session = new MockHttpSession();
+ TimingAttackSessionState state = initializeState(session);
+ String tag = tagFor(state, TimingAttackSessionState.KNOWN_MESSAGE);
+ String noisyTag = tagFor(state, TimingAttackSessionState.NOISY_MESSAGE);
+
+ mockMvc
+ .perform(post("/crypto/timing/tag").session(session).param("signature", tag))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+
+ mockMvc
+ .perform(post("/crypto/timing/noisy-tag").session(session).param("signature", noisyTag))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+ }
+
+ @Test
+ void differentSessionsHaveIndependentState() throws Exception {
+ TimingAttackSessionState first = initializeState(new MockHttpSession());
+ TimingAttackSessionState second = initializeState(new MockHttpSession());
+
+ assertThat(first).isNotSameAs(second);
+ }
+
+ @Test
+ void oracleLimitsConcurrentRequestsForOneSession() throws Exception {
+ MockHttpSession session = new MockHttpSession();
+ TimingAttackSessionState state = initializeState(session);
+ for (int i = 0; i < TimingAttackSessionState.MAX_CONCURRENT_REQUESTS; i++) {
+ assertThat(state.tryAcquireRequestSlot()).isTrue();
+ }
+
+ try {
+ mockMvc
+ .perform(get("/crypto/timing/verify").session(session).param("signature", "00000000"))
+ .andExpect(status().isTooManyRequests());
+ } finally {
+ for (int i = 0; i < TimingAttackSessionState.MAX_CONCURRENT_REQUESTS; i++) {
+ state.releaseRequestSlot();
+ }
+ }
+ }
+
+ @Test
+ void mitigationRequiresTheRootCauseAndAppropriateApi() throws Exception {
+ mockMvc
+ .perform(
+ post("/crypto/timing/mitigation")
+ .param("cause", "early-exit")
+ .param("mitigation", "random-delay"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false))
+ .andExpect(jsonPath("$.feedback").value(containsString("secret-dependent early exit")));
+
+ mockMvc
+ .perform(
+ post("/crypto/timing/mitigation")
+ .param("cause", "early-exit")
+ .param("mitigation", "message-digest"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+ }
+
+ private TimingAttackSessionState initializeState(MockHttpSession session) throws Exception {
+ mockMvc
+ .perform(get("/crypto/timing/verify").session(session))
+ .andExpect(status().isOk())
+ .andExpect(content().string(TimingAttackOracle.INVALID));
+
+ return Collections.list(session.getAttributeNames()).stream()
+ .map(session::getAttribute)
+ .filter(TimingAttackSessionState.class::isInstance)
+ .map(TimingAttackSessionState.class::cast)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static String tagFor(TimingAttackSessionState state, String message) {
+ return HexFormat.of().formatHex(state.tagFor(message));
+ }
+
+ private static String incorrectTagFor(TimingAttackSessionState state, String message) {
+ byte[] tag = state.tagFor(message);
+ tag[0] ^= 1;
+ return HexFormat.of().formatHex(tag);
+ }
+}
diff --git a/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java b/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java
new file mode 100644
index 00000000000..0667ea3589f
--- /dev/null
+++ b/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java
@@ -0,0 +1,165 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.httpparameterpollution;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.owasp.webgoat.container.plugins.LessonTest;
+
+class HttpParameterPollutionTest extends LessonTest {
+
+ @Test
+ void singleParameterShowsBothInterpretationsWithoutCompletingTheAssignment() throws Exception {
+ mockMvc
+ .perform(get("/HttpParameterPollution/parameters").param("name", "WebGoat"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false))
+ .andExpect(jsonPath("$.output").value(containsString("request contains 1")))
+ .andExpect(jsonPath("$.output").value(containsString("WebGoat")))
+ .andExpect(jsonPath("$.output").value(containsString("[WebGoat]")));
+ }
+
+ @Test
+ void differentDuplicateParametersCompleteTheObservationAssignment() throws Exception {
+ mockMvc
+ .perform(
+ get("/HttpParameterPollution/parameters")
+ .param("name", "WebGoat")
+ .param("name", "OWASP"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true))
+ .andExpect(jsonPath("$.output").value(containsString("request contains 2")))
+ .andExpect(jsonPath("$.output").value(containsString("one value reads: WebGoat")))
+ .andExpect(jsonPath("$.output").value(containsString("[WebGoat, OWASP]")));
+ }
+
+ @Test
+ void equalDuplicateParametersDoNotCompleteTheObservationAssignment() throws Exception {
+ mockMvc
+ .perform(
+ get("/HttpParameterPollution/parameters")
+ .param("name", "WebGoat")
+ .param("name", "WebGoat"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void allowedTransferDoesNotCompleteTheExploitAssignment() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "alice")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false))
+ .andExpect(jsonPath("$.feedback").value(containsString("transfer to alice succeeded")));
+ }
+
+ @Test
+ void directTransferToAttackerIsBlocked() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "attacker")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void duplicateRecipientCanExploitTheInterpretationMismatch() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "alice")
+ .param("recipient", "attacker")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+ }
+
+ @Test
+ void reverseRecipientOrderFailsValidation() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "attacker")
+ .param("recipient", "alice")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void lastOfThreeRecipientValuesControlsTheTransfer() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "alice")
+ .param("recipient", "attacker")
+ .param("recipient", "bob")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "alice")
+ .param("recipient", "bob")
+ .param("recipient", "attacker")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(true));
+ }
+
+ @Test
+ void missingRecipientFailsCleanly() throws Exception {
+ mockMvc
+ .perform(post("/HttpParameterPollution/transfer").param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void missingAmountFailsCleanly() throws Exception {
+ mockMvc
+ .perform(post("/HttpParameterPollution/transfer").param("recipient", "alice"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void duplicateAmountFailsCleanly() throws Exception {
+ mockMvc
+ .perform(
+ post("/HttpParameterPollution/transfer")
+ .param("recipient", "alice")
+ .param("recipient", "attacker")
+ .param("amount", "100")
+ .param("amount", "100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted").value(false));
+ }
+
+ @Test
+ void mitigationAcceptsExactlyOneRecipient() {
+ assertEquals("alice", ParameterPollutionMitigation.requireSingleValue(new String[] {"alice"}));
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(
+ () ->
+ ParameterPollutionMitigation.requireSingleValue(
+ new String[] {"alice", "attacker"}));
+ }
+
+}
diff --git a/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java b/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java
new file mode 100644
index 00000000000..07c63fff121
--- /dev/null
+++ b/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.sqlinjection.mitigation;
+
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.jsoup.Jsoup;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.owasp.webgoat.container.plugins.LessonTest;
+
+class SqlInjectionMitigationsFormTest extends LessonTest {
+
+ @ParameterizedTest
+ @CsvSource(
+ delimiter = '|',
+ value = {
+ "userid_sql_only_input_validation|Smith';SELECT/**/*/**/from/**/user_system_data;--",
+ "userid_sql_only_input_validation_on_keywords|Smith';SESELECTLECT/**/*/**/FRFROMOM/**/user_system_data;--"
+ })
+ void inputValidationFormsSubmitToWorkingEndpoints(String inputName, String solution)
+ throws Exception {
+ var response =
+ mockMvc
+ .perform(get("/WebGoat/SqlInjectionMitigations.lesson").contextPath("/WebGoat"))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse();
+ var form =
+ Jsoup.parse(response.getContentAsString())
+ .selectFirst("form:has(input[name=" + inputName + "])");
+ assertNotNull(form, "The lesson must contain the input validation form");
+ assertEquals("POST", form.attr("method").toUpperCase(java.util.Locale.ROOT));
+
+ mockMvc
+ .perform(post(form.attr("action")).contextPath("/WebGoat").param(inputName, solution))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lessonCompleted", is(true)));
+ }
+}
diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java b/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java
index a087951d188..1e1897dc90e 100644
--- a/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java
+++ b/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java
@@ -9,10 +9,12 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.file.Path;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.owasp.webgoat.WithWebGoatUser;
import org.owasp.webgoat.container.plugins.LessonTest;
import org.springframework.http.MediaType;
@@ -22,6 +24,8 @@
@WithWebGoatUser
class ContentTypeAssignmentTest extends LessonTest {
+ @TempDir Path tempDir;
+
@BeforeEach
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
@@ -49,9 +53,7 @@ void workingAttack() throws Exception {
.perform(
MockMvcRequestBuilders.post("/xxe/content-type")
.contentType(MediaType.APPLICATION_XML)
- .content(
- " ]>&root; "))
+ .content(XXETestPayload.readKnownFile(tempDir)))
.andExpect(status().isOk())
.andExpect(
jsonPath("$.feedback", CoreMatchers.is(messages.getMessage("assignment.solved"))));
diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java b/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java
index 6da26ddcf4a..ff8016d7cbc 100644
--- a/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java
+++ b/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java
@@ -7,9 +7,11 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import java.nio.file.Path;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.owasp.webgoat.WithWebGoatUser;
import org.owasp.webgoat.container.plugins.LessonTest;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
@@ -18,6 +20,8 @@
@WithWebGoatUser
class SimpleXXETest extends LessonTest {
+ @TempDir Path tempDir;
+
@BeforeEach
void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
@@ -29,9 +33,7 @@ void workingAttack() throws Exception {
mockMvc
.perform(
MockMvcRequestBuilders.post("/xxe/simple")
- .content(
- " ]>&root; "))
+ .content(XXETestPayload.readKnownFile(tempDir)))
.andExpect(status().isOk())
.andExpect(
jsonPath("$.feedback", CoreMatchers.is(messages.getMessage("assignment.solved"))));
diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java b/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java
new file mode 100644
index 00000000000..bf2e5ab8d31
--- /dev/null
+++ b/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: Copyright © 2017 WebGoat authors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+package org.owasp.webgoat.lessons.xxe;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+final class XXETestPayload {
+
+ private XXETestPayload() {}
+
+ static String readKnownFile(Path directory) throws IOException {
+ Path target = Files.writeString(directory.resolve("xxe.txt"), "etc Windows");
+
+ return """
+
+ ]>
+ &root;
+ """
+ .formatted(target.toUri());
+ }
+}