From 416d2a1b4a515155b44de9e4b8b7d029261308da Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 11 Oct 2026 12:36:11 +0100 Subject: [PATCH 1/6] docs(cryptography): explain timing attack solutions (#2577) Learners need concrete steps to interpret the timing measurements after each attack. Matching heading sizes also make the lesson pages easier to follow. --- .../lessons/TimingAttacksLessonUITest.java | 14 +++++++++++++- .../timing-attacks-discovery-solution.adoc | 13 +++++++++++++ .../timing-attacks-discovery.adoc | 2 +- .../timing-attacks-introduction.adoc | 2 +- .../documentation/timing-attacks-jwt.adoc | 2 +- .../timing-attacks-mitigation.adoc | 2 +- .../timing-attacks-noise-solution.adoc | 13 +++++++++++++ .../documentation/timing-attacks-noise.adoc | 2 +- .../timing-attacks-recovery-solution.adoc | 13 +++++++++++++ .../documentation/timing-attacks-recovery.adoc | 2 +- .../cryptography/html/TimingAttacks.html | 18 ++++++++++++++++++ .../cryptography/timing/TimingAttacksTest.java | 3 +++ 12 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery-solution.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-noise-solution.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery-solution.adoc diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java index 6d784a4d228..fff984ffcad 100644 --- a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java @@ -42,12 +42,24 @@ void shouldRecoverTheFirstByte() { assertThat(lessonPage.firstByteFeedback()) .containsText("The response timing revealed the first byte"); + + lessonPage.navigateTo(3); + assertThat(lessonPage.getPage().locator(".lesson-page-solution:visible")) + .containsText("Try all 256 first-byte values"); + + lessonPage.navigateTo(5); + assertThat(lessonPage.getPage().locator(".lesson-page-solution:visible")) + .containsText("signature=ab010000"); + + lessonPage.navigateTo(7); + assertThat(lessonPage.getPage().locator(".lesson-page-solution:visible")) + .containsText("verify-noisy"); } @Test @DisplayName("Reject an incomplete mitigation and accept the constant-time comparison API") void shouldReviewTheMitigation() { - lessonPage.navigateTo(5); + lessonPage.navigateTo(8); lessonPage.submitMitigation("early-exit", "random-delay"); assertThat(lessonPage.mitigationFeedback()) diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery-solution.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery-solution.adoc new file mode 100644 index 00000000000..4abcfc2848b --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery-solution.adoc @@ -0,0 +1,13 @@ +=== Assignment solution + +The server compares the supplied tag with the expected tag from left to right. A wrong first byte ends the comparison at once. A correct first byte makes the server wait about 25 milliseconds before checking the second byte. Both requests can still return `Invalid signature`. + +Copy the `JSESSIONID` cookie from your signed-in WebGoat browser session and replace `` below. Use that same cookie for every request so the server keeps the same HMAC secret. These two requests test `00` and `01` as the first byte while keeping the other bytes at zero: + +[source,shell] +---- +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify?message=WebGoat&signature=00000000" +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify?message=WebGoat&signature=01000000" +---- + +Check that both responses say `Invalid signature`. Try all 256 first-byte values, from `00` through `ff`, and repeat each request. `time_total` includes network and server time, so compare typical durations rather than trusting one measurement. The candidate that consistently takes about 25 milliseconds longer reveals the first byte for your session. Submit that byte as two hexadecimal characters. A new session gets a different tag. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc index 5bbeb83019e..757721a382a 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc @@ -1,4 +1,4 @@ -== Discover the side channel +=== Discover the side channel Send candidate tags to this endpoint: diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc index b147a78cfa6..be706e69783 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc @@ -1,4 +1,4 @@ -== Timing attacks +=== 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. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc index 78820b32d07..55dde99177e 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc @@ -1,4 +1,4 @@ -== Connection to JWT +=== Connection to JWT JWT tokens that use HS256 authenticate this signing input: diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc index 4d4c05288c4..f5b33d915fb 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc @@ -1,4 +1,4 @@ -== Review the repair +=== Review the repair The vulnerable comparison has a secret-dependent early exit: diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise-solution.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise-solution.adoc new file mode 100644 index 00000000000..3ae28b1f850 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise-solution.adoc @@ -0,0 +1,13 @@ +=== Assignment solution + +This endpoint authenticates `WebGoat with noise`, so it calculates a different tag from the previous assignment. It adds 0 to 8 milliseconds of random delay to every request, while each matching byte adds about 2 milliseconds. One slow response is therefore not enough to identify a byte. + +Start with first-byte candidates such as these. Keep the same `JSESSIONID` cookie, and encode the spaces in the message as `%20`: + +[source,shell] +---- +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify-noisy?message=WebGoat%20with%20noise&signature=00000000" +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify-noisy?message=WebGoat%20with%20noise&signature=01000000" +---- + +Test each candidate several times with the known prefix fixed. Compare a median or trimmed mean, keep the candidates with the longest measured times, and collect more samples for those candidates. Repeat for each byte. A matching prefix shifts the distribution of response times even though individual requests overlap. Do not reuse the tag from the `WebGoat` message. Confirm the complete tag when this endpoint returns `Valid signature`. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc index 7dea2c730fb..acf58003adf 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc @@ -1,4 +1,4 @@ -== Extract a noisy signal +=== 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: diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery-solution.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery-solution.adoc new file mode 100644 index 00000000000..1b0710708e2 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery-solution.adoc @@ -0,0 +1,13 @@ +=== Assignment solution + +Each matching byte adds about 5 milliseconds before the server checks the next byte. Keep the first byte you recovered, vary the second byte, and pad the rest of the tag with zeros. The slowest candidate has probably matched one more byte. Repeat this process for the third and fourth bytes, measuring each candidate more than once. + +If the first byte in your session were `ab`, these requests would test `00` and `01` as the second byte. Replace `ab` with the byte you measured and use the same `JSESSIONID` as before: + +[source,shell] +---- +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify-tag?message=WebGoat&signature=ab000000" +curl -sS --cookie "JSESSIONID=" --write-out "\nTotal: %{time_total}s\n" "http://localhost:8080/WebGoat/crypto/timing/verify-tag?message=WebGoat&signature=ab010000" +---- + +Once you find the second byte, keep it fixed while testing the third. For example, `ab120000` tests the prefix `ab12`. When all four bytes match, the endpoint returns `Valid signature`. Submit that four-byte tag for the current session. You recovered the authentication value through the comparison delay, not the HMAC secret. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc index 05043995ec6..6a16a22f0bc 100644 --- a/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc @@ -1,4 +1,4 @@ -== Recover the tag +=== Recover the tag The next endpoint reduces the delay to about 5 milliseconds per matching byte: diff --git a/src/main/resources/lessons/cryptography/html/TimingAttacks.html b/src/main/resources/lessons/cryptography/html/TimingAttacks.html index c0ab0cc81d6..86608f33f59 100644 --- a/src/main/resources/lessons/cryptography/html/TimingAttacks.html +++ b/src/main/resources/lessons/cryptography/html/TimingAttacks.html @@ -20,6 +20,12 @@ +
+
+
+
+
+
@@ -34,6 +40,12 @@
+
+
+
+
+
+
@@ -48,6 +60,12 @@
+
+
+
+
+
+
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 index 66b986256e7..606308271ba 100644 --- a/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java +++ b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java @@ -44,6 +44,9 @@ void lessonPageRenders() throws Exception { .perform(get("/TimingAttacks.lesson")) .andExpect(status().isOk()) .andExpect(content().string(containsString("Discover the side channel"))) + .andExpect(content().string(containsString("Try all 256 first-byte values"))) + .andExpect(content().string(containsString("signature=ab010000"))) + .andExpect(content().string(containsString("This endpoint authenticates"))) .andExpect(content().string(containsString("MessageDigest.isEqual"))) .andExpect(content().string(containsString("Connection to JWT"))); } From 4441470e4c1a828f56f39cbea578101f9464c64d Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 11 Oct 2026 12:42:23 +0100 Subject: [PATCH 2/6] feat(challenges): add chained security scenario (#2570) Add a partner feed recovery scenario that combines multiple application weaknesses, with unit, integration, and browser coverage. Update exercise language to focus on investigation and composition, and make lesson documentation changes visible during Spring Boot development. The updated challenge feedback and German category label caused existing integration checks to fail even though the exercises still work. --- WEBGOAT_IN_THE_AGE_OF_AI.md | 425 ++++++++++++++++++ pom.xml | 2 + .../Challenge2IntegrationTest.java | 90 ++++ .../integration/ChallengeIntegrationTest.java | 15 +- .../webgoat/lessons/Challenge2UITest.java | 63 +++ .../challenges/challenge2/Assignment2.java | 38 ++ .../challenge2/ChainedFetchService.java | 110 +++++ .../challenge2/ChainedXmlService.java | 98 ++++ .../challenges/challenge2/Challenge2.java | 23 + .../challenge2/Challenge2State.java | 47 ++ .../challenge2/FeedImportEndpoint.java | 38 ++ .../challenge2/InternalDiagnostic.java | 34 ++ .../challenge2/NorthstarEndpoints.java | 30 ++ src/main/resources/i18n/messages.properties | 2 +- .../resources/i18n/messages_de.properties | 2 +- .../resources/i18n/messages_es.properties | 2 +- .../resources/i18n/messages_fr.properties | 2 +- .../resources/i18n/messages_nl.properties | 2 +- .../lessons/challenges/css/challenge-flag.css | 7 + .../lessons/challenges/css/challenge2.css | 62 +++ .../documentation/Challenge_introduction.adoc | 33 +- .../Challenge_introduction_es.adoc | 29 +- .../lessons/challenges/html/Challenge1.html | 8 +- .../lessons/challenges/html/Challenge2.html | 67 +++ .../lessons/challenges/html/Challenge5.html | 8 +- .../lessons/challenges/html/Challenge6.html | 8 +- .../lessons/challenges/html/Challenge7.html | 8 +- .../lessons/challenges/html/Challenge8.html | 10 +- .../challenges/i18n/WebGoatLabels.properties | 16 +- .../i18n/WebGoatLabels_de.properties | 4 +- .../i18n/WebGoatLabels_fr.properties | 4 +- .../i18n/WebGoatLabels_nl.properties | 4 +- .../lessons/challenges/js/challenge2.js | 26 ++ .../lessons/challenges/Assignment1Test.java | 2 +- .../challenges/challenge2/Challenge2Test.java | 186 ++++++++ 35 files changed, 1433 insertions(+), 72 deletions(-) create mode 100644 WEBGOAT_IN_THE_AGE_OF_AI.md create mode 100644 src/it/java/org/owasp/webgoat/integration/Challenge2IntegrationTest.java create mode 100644 src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/Challenge2UITest.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Assignment2.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedFetchService.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedXmlService.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2State.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/FeedImportEndpoint.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/InternalDiagnostic.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/NorthstarEndpoints.java create mode 100644 src/main/resources/lessons/challenges/css/challenge-flag.css create mode 100644 src/main/resources/lessons/challenges/css/challenge2.css create mode 100644 src/main/resources/lessons/challenges/html/Challenge2.html create mode 100644 src/main/resources/lessons/challenges/js/challenge2.js create mode 100644 src/test/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2Test.java diff --git a/WEBGOAT_IN_THE_AGE_OF_AI.md b/WEBGOAT_IN_THE_AGE_OF_AI.md new file mode 100644 index 00000000000..fcd50a8fc2c --- /dev/null +++ b/WEBGOAT_IN_THE_AGE_OF_AI.md @@ -0,0 +1,425 @@ +# WebGoat in the age of AI + +## Background + +AI-assisted development is changing how people write software and find security vulnerabilities. Given an endpoint, source code, and an objective such as "perform an SQL injection" or "exploit this XXE vulnerability," an AI assistant can often produce a working payload before the learner understands the flaw. + +This raises a question for WebGoat: if AI can perform much of the technical work, what should WebGoat teach? + +WebGoat still has an important role, but its exercises should account for the tools that developers and security practitioners now use. WebGoat should teach how vulnerable systems behave, how weaknesses combine, and how to verify a repair. Remembering the syntax of a particular exploit remains useful, but it should not be the final learning objective. + +## The original WebGoat philosophy still applies + +This problem is not new. WebGoat has sometimes randomized exercise values so automated security tools could not solve a lesson by replaying a fixed answer. The underlying principle was simple: + +> It is your responsibility to learn. It is not WebGoat's responsibility to prevent you from automating the exercise. + +A learner can point a scanner at an SQL injection lesson and complete it without learning much. AI creates a similar problem, though it can also explain code, investigate several components, and propose a repair. + +Trying to stop AI from solving WebGoat exercises would lead to an arms race that does little to teach application security. WebGoat should not become an anti-AI CTF. Learners should be free to use the tools they would use in practice, including: + +- Browser developer tools +- Intercepting proxies such as Burp Suite +- Scripts and security scanners +- IDEs +- AI assistants and coding agents + +Completing an exercise and understanding it are different outcomes. WebGoat should design for understanding. + +## Keep the focused lessons + +The existing lessons remain important. They give learners the vocabulary and technical foundation needed to understand application security. These lessons cover topics such as SQL injection, cross-site scripting, XXE, SSRF, JWT, access control, insecure deserialization, password hashing, timing side channels, and HTTP parameter pollution. + +A learner asking "How does XXE work?" should be able to open the XXE lesson and study that vulnerability in a controlled setting. + +The focused lessons are the textbook. They explain one security concept at a time. + +## Add advanced scenarios + +An advanced section could build on the focused lessons. Each scenario would give the learner an objective without naming the vulnerability needed to reach it. + +Instead of this instruction: + +> Perform an XXE attack against this endpoint. + +The learner might receive this objective: + +> An administrative endpoint cannot be accessed externally. Retrieve the information it exposes. + +The learner would investigate the application and discover the attack path: + +```text +XML input + | + v +External entity processing + | + v +Server-side request + | + v +Internal service + | + v +Trusted internal request + | + v +Sensitive response +``` + +An AI assistant may generate the XML payload in seconds. The learner still needs to understand: + +- Which components exist and how they communicate +- Where the trust boundaries are +- Which assumptions make the system vulnerable +- How separate weaknesses form a complete attack +- Why the attack succeeds + +## Do not reveal the vulnerability classes + +An advanced exercise named "XXE and SSRF challenge" gives away much of the investigation. Scenario-oriented names preserve the discovery work. Possible names include: + +- Internal services +- Trust boundaries +- Account recovery +- Administrative access +- Unexpected parameters + +The learner should identify the relevant vulnerability classes while investigating the scenario. + +## Link back to focused lessons + +Advanced scenarios do not need to explain every vulnerability again. They can offer optional links to the focused lessons: + +> Not familiar with XML external entities? Complete the XXE lesson first. +> +> This scenario involves server-side requests. The SSRF lesson explains the related trust boundaries. + +This creates a clear progression: + +```text +Learn a security concept + | + v +Exploit it in isolation + | + v +Recognize it in a larger system + | + v +Combine it with other weaknesses + | + v +Explain and repair the complete attack +``` + +The existing lessons become building blocks for more complex scenarios. + +## Candidate scenarios + +### XXE, SSRF, and an internal service + +Objective: retrieve information from an administrative endpoint that cannot be accessed externally. + +```text +XML parser + | + v +External entity processing + | + v +Server-side request forgery + | + v +Internal service endpoint + | + v +Internal authentication or trusted-header behavior + | + v +Sensitive information +``` + +This is a good first scenario because it combines several security concepts in an architecture that remains small enough to understand. + +### Open redirect and OAuth + +Objective: obtain access to a protected resource belonging to another user. + +```text +Open redirect + | + v +Weak redirect URI validation + | + v +OAuth authorization flow + | + v +Authorization code or token exposure + | + v +Protected resource +``` + +An open redirect can look harmless in isolation. Placing it in an authorization flow shows how another system can turn it into an account security problem. + +### HTTP parameter pollution and authorization + +Objective: perform an operation on an account that the current user does not control. + +```text +Duplicate parameters + | + v +Different parser interpretations + | + +---- Authorization component reads Alice + | + +---- Business logic reads Bob + | + v + Unauthorized operation +``` + +This scenario teaches the effect of parser disagreement across a trust boundary, not merely the existence of duplicate HTTP parameters. + +### Stored XSS and a privileged action + +Objective: change a protected application setting without the required privileges. + +```text +Attacker-controlled content + | + v + Stored XSS + | + v +Privileged user views the content + | + v +Authenticated action runs in that user's session + | + v +Protected setting changes +``` + +This shows why stored XSS matters beyond producing `alert(1)`. + +### IDOR, information disclosure, and credential reuse + +Objective: access protected administration functionality. + +```text +Insecure direct object reference + | + v +Information disclosure + | + v +Credential or token exposure + | + v +Credential reuse + | + v +Administrative functionality +``` + +This scenario shows why the severity of a weakness depends on the surrounding system and the other information an attacker can reach. + +### Path traversal and configuration disclosure + +Objective: retrieve a protected customer record. + +```text +Path traversal + | + v +Application configuration + | + v +Credentials or connection information + | + v +Protected component + | + v +Customer data +``` + +Path traversal becomes one step in compromising a larger system instead of the final objective. + +### JWT and information disclosure + +Objective: access a resource unavailable to the current user. + +The learner receives a valid JWT but is not told that the token is relevant. Elsewhere in the application, the learner can discover configuration or cryptographic information. The existing JWT lessons provide the knowledge needed to recognize why that information matters. + +### Race condition and a business rule + +Objective: obtain something the account should receive only once. + +The application may enforce its checks when it handles requests one at a time. Concurrent requests break an application rule. AI can generate the concurrency script, but the learner must identify the rule and the timing window that breaks it. + +## Continue beyond exploitation + +Exploitation does not need to end an advanced lesson. A complete exercise can follow this sequence: + +```text +Discover + | + v +Exploit + | + v +Explain + | + v +Repair + | + v +Verify +``` + +After the learner completes an attack, WebGoat could present a proposed developer fix. The learner would determine whether it removes the underlying weakness. + +### Example: an incomplete SSRF fix + +Suppose the learner uses XXE to make the server request: + +```text +http://localhost:8080/internal +``` + +The development team blocks the string `localhost` and claims the problem is fixed. WebGoat then asks the learner to verify the repair. + +The learner discovers that the patch blocks one representation of the destination but leaves the trust problem in place. A proper repair might disable external entity processing, restrict outbound requests, or break the attack chain at another appropriate trust boundary. + +The lesson teaches the difference between blocking one demonstrated payload and removing the vulnerability. + +## Review proposed security fixes + +Developers will review and maintain code they did not write themselves. Some of that code will come from AI coding assistants. An advanced WebGoat exercise could present several plausible patches and ask the learner to determine whether each patch fixes the problem. + +A patch might: + +- Block only the demonstrated payload +- Deny one dangerous value while allowing equivalent values +- Validate input in the wrong component +- Introduce another vulnerability +- Remove the root cause + +The learner's task is to understand the system well enough to judge the proposed code. This skill remains useful whether a person or an AI assistant wrote the patch. + +## Require evidence of understanding + +If AI can supply an exploit, an advanced lesson should require more than the final payload. The learner could be asked to: + +- Identify the failed trust boundary +- Reconstruct the attack chain +- Explain why each step enables the next +- Choose between several proposed repairs +- Predict whether a modified exploit will still work +- Verify the repair with a regression test + +These tasks make the learning objective explicit and give WebGoat a way to check understanding. + +## A small advanced application + +The advanced section could eventually use a small fictional application rather than a set of unrelated challenges. The learner would become familiar with its architecture over several exercises. + +```text +Browser + | + v +Frontend and API + | + +-------- Authentication service + | + +-------- Document service + | + +-------- Internal admin service + | + +-------- External integrations +``` + +Early exercises could reveal information about the architecture that becomes useful later. This would let vulnerabilities combine through normal component interactions. The learner would reason about a system instead of treating every vulnerability as an isolated puzzle. + +A shared application also costs more to build and maintain. It needs reliable state reset, stable dependencies, progressive hints, and tests for every supported attack path. It makes sense as a later step after WebGoat validates the lesson format with one smaller scenario. + +## The role of AI + +WebGoat should assume that learners have access to AI. The design question for each advanced lesson should be: + +> If an AI assistant generates the exploit in 20 seconds, what does the learner still need to understand? + +If the answer is "nothing," the exercise may still demonstrate a basic vulnerability, but it does not belong in the advanced section. + +An advanced lesson remains useful when the learner must reason about application architecture, protocol behavior, parser differences, authorization decisions, attack composition, root causes, and verification. + +## Proposed learning model + +```text ++---------------------------------+ +| Focused lessons | +| | +| Learn individual security | +| concepts and vulnerabilities | ++----------------+----------------+ + | + v ++---------------------------------+ +| Advanced scenarios | +| | +| Discover which concepts apply | +| and combine them | ++----------------+----------------+ + | + v ++---------------------------------+ +| Remediation and review | +| | +| Fix the root cause and verify | +| that the attack chain is gone | ++---------------------------------+ +``` + +Focused lessons are the textbook. Advanced scenarios are the lab. + +## A practical first version + +WebGoat can test this approach with one advanced scenario before building a larger application: + +1. Build one attack chain from concepts already taught in focused lessons. +2. Give the learner an outcome-based objective without vulnerability names. +3. Add optional hints that point back to the relevant focused lessons. +4. Require the learner to explain the attack chain after exploitation. +5. Present several repairs and ask the learner to test them. +6. Check whether the learner can identify the root cause and verify the final repair. + +The XXE and SSRF scenario is a good candidate because it has a clear objective, visible trust boundaries, and several places where an incomplete fix can fail. + +## Design principle + +WebGoat should teach security rather than test whether somebody can avoid automation. Learners should be free to use the tools they would use in practice, including AI. WebGoat's responsibility is to provide exercises that still require something worth learning. + +The learning path is: + +```text +Learn the concepts + | + v +Exploit them in isolation + | + v +Combine them in a system + | + v +Explain why the attack works + | + v +Fix the root cause and verify the repair +``` + +WebGoat should not focus on teaching people to type exploits. It should teach them how vulnerable software behaves and how to determine whether it has been fixed. diff --git a/pom.xml b/pom.xml index 4ab176f8c04..92f9f8f6596 100644 --- a/pom.xml +++ b/pom.xml @@ -477,6 +477,8 @@ org.springframework.boot spring-boot-maven-plugin + + true true true org.owasp.webgoat.server.StartWebGoat diff --git a/src/it/java/org/owasp/webgoat/integration/Challenge2IntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/Challenge2IntegrationTest.java new file mode 100644 index 00000000000..552cc4c31b8 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/integration/Challenge2IntegrationTest.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.integration; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class Challenge2IntegrationTest extends IntegrationTest { + + @Test + void recoverTheFlagThroughTheFeedImporter() { + startLesson("Challenge2"); + + String menu = + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .get(webGoatUrlConfig.url("service/lessonmenu.mvc")) + .then() + .statusCode(200) + .extract() + .asString(); + assertThat(menu).contains("Challenge2.lesson"); + assertThat(menu.indexOf("Challenge2.lesson")).isGreaterThan(menu.indexOf("Challenge1.lesson")); + assertThat(menu.indexOf("Challenge2.lesson")).isLessThan(menu.indexOf("Challenge5.lesson")); + + String diagnosticUrl = webGoatUrlConfig.url("northstar/internal/diagnostics"); + var directDiagnostic = given() + .cookie("JSESSIONID", getWebGoatCookie()) + .get(diagnosticUrl); + assertThat(directDiagnostic.statusCode()) + .describedAs("%s returned %s", diagnosticUrl, directDiagnostic.asString()) + .isEqualTo(401); + + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .get(webGoatUrlConfig.url("northstar/feeds")) + .then() + .statusCode(200) + .body(equalTo("Partner bulletin: the evening edition is ready for review.")); + + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .redirects() + .follow(false) + .queryParam("url", "https://example.org") + .get(webGoatUrlConfig.url("northstar/go")) + .then() + .statusCode(302) + .header("Location", equalTo("https://example.org")); + + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("xml", "Evening bulletin") + .post(webGoatUrlConfig.url("northstar/import")) + .then() + .statusCode(200) + .body("preview", is("Evening bulletin")); + + String internalUrl = webGoatUrlConfig.url("northstar/internal/diagnostics"); + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("xml", entityFeed(internalUrl)) + .post(webGoatUrlConfig.url("northstar/import")) + .then() + .statusCode(400); + + var chainedImport = + given() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("xml", entityFeed("go?url=internal%2Fdiagnostics")) + .post(webGoatUrlConfig.url("northstar/import")); + assertThat(chainedImport.statusCode()).describedAs(chainedImport.asString()).isEqualTo(200); + String flag = chainedImport.path("preview"); + + checkAssignment(webGoatUrlConfig.url("northstar/recovery"), Map.of("flag", "bad"), false); + checkAssignment(webGoatUrlConfig.url("northstar/recovery"), Map.of("flag", flag), true); + checkResults("Challenge2"); + } + + private String entityFeed(String url) { + return "]>&flag;"; + } +} diff --git a/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java index bdb06569a8f..da3511da374 100644 --- a/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java @@ -8,12 +8,17 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; public class ChallengeIntegrationTest extends IntegrationTest { + private static final Pattern FLAG_PATTERN = + Pattern.compile("\\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\\b"); + @Test void testChallenge1() { startLesson("Challenge1"); @@ -48,7 +53,7 @@ void testChallenge1() { .extract() .asString(); - String flag = result.substring(result.indexOf("flag") + 6, result.indexOf("flag") + 42); + String flag = extractFlag(result); params.clear(); params.put("flag", flag); checkAssignment(webGoatUrlConfig.url("challenge/flag/1"), params, true); @@ -77,7 +82,7 @@ void testChallenge5() { .extract() .asString(); - String flag = result.substring(result.indexOf("flag") + 6, result.indexOf("flag") + 42); + String flag = extractFlag(result); params.clear(); params.put("flag", flag); checkAssignment(webGoatUrlConfig.url("challenge/flag/5"), params, true); @@ -134,4 +139,10 @@ void testChallenge7() { String flag = result.substring(result.indexOf("flag") + 6, result.indexOf("flag") + 42); checkAssignment(webGoatUrlConfig.url("challenge/flag/7"), Map.of("flag", flag), true); } + + private String extractFlag(String result) { + Matcher matcher = FLAG_PATTERN.matcher(result); + Assertions.assertThat(matcher.find()).as("challenge response contains a flag").isTrue(); + return matcher.group(); + } } diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/Challenge2UITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/Challenge2UITest.java new file mode 100644 index 00000000000..8988435153c --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/Challenge2UITest.java @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.lessons; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page.GetByRoleOptions; +import com.microsoft.playwright.options.AriaRole; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.lessons.LessonName; +import org.owasp.webgoat.playwright.webgoat.PlaywrightTest; +import org.owasp.webgoat.playwright.webgoat.helpers.Authentication; +import org.owasp.webgoat.playwright.webgoat.pages.lessons.LessonPage; + +public class Challenge2UITest extends PlaywrightTest { + + @Test + void recoverAndSubmitTheFlagThroughTheFeedImporter(Browser browser) { + var page = Authentication.sylvester(browser); + var lessonPage = new LessonPage(page); + var lessonName = new LessonName("Challenge2"); + lessonPage.resetLesson(lessonName); + lessonPage.open(lessonName); + + assertThat(page.locator(".partner-feed-dashboard h3")).hasText("Partner feed recovery"); + + var preview = page.locator("#partner-feed-preview"); + var previewButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Preview feed")); + var submitButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Submit result")); + previewButton.click(); + assertThat(preview).hasText("Evening bulletin"); + + page.locator("#partner-feed-xml") + .fill(entityFeed(page.locator("#partner-feed-diagnostic-link").getAttribute("href"))); + previewButton.click(); + assertThat(preview).hasText("XML could not be processed within this lesson"); + + page.locator("#partner-feed-xml").fill(entityFeed("go?url=internal%2Fdiagnostics")); + previewButton.click(); + assertThat(preview) + .hasText(Pattern.compile("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")); + String flag = preview.textContent().strip(); + + page.locator("#partner-feed-result").fill("wrong-result"); + submitButton.click(); + assertThat(page.locator(".partner-feed-submit .attack-feedback")) + .containsText("That result is not linked to a completed import in this session."); + + page.locator("#partner-feed-result").fill(flag); + submitButton.click(); + assertThat(page.locator(".partner-feed-submit .attack-feedback")) + .containsText("Result accepted. The feed importer reached the diagnostic service"); + assertThat(page.locator(".partner-feed-submit .assignment-success i")).isVisible(); + } + + private String entityFeed(String url) { + return "]>&flag;"; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Assignment2.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Assignment2.java new file mode 100644 index 00000000000..861e5cf56e3 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Assignment2.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +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.RestController; + +@RestController +@AssignmentHints({ + "challenge2.chain.hint1", + "challenge2.chain.hint2", + "challenge2.chain.hint3" +}) +public class Assignment2 implements AssignmentEndpoint { + + private final Challenge2State state; + + public Assignment2(Challenge2State state) { + this.state = state; + } + + @PostMapping("/northstar/recovery") + public AttackResult solve(@RequestParam String flag) { + if (state.completedXmlChain() && state.flag().equalsIgnoreCase(flag.trim())) { + return success(this).feedback("challenge2.chain.success").build(); + } + return failed(this).feedback("challenge2.chain.failure").build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedFetchService.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedFetchService.java new file mode 100644 index 00000000000..26b04749088 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedFetchService.java @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class ChainedFetchService { + + static final String FEED_PATH = "/feeds"; + static final String REDIRECT_PATH = "/go"; + static final String INTERNAL_PATH = "/internal/diagnostics"; + private static final String IMPORT_PATH = "/import"; + private static final int MAX_REDIRECTS = 4; + private static final int MAX_URL_LENGTH = 4096; + + private final InternalDiagnostic diagnostic; + private final NorthstarEndpoints endpoints; + + public ChainedFetchService(InternalDiagnostic diagnostic, NorthstarEndpoints endpoints) { + this.diagnostic = diagnostic; + this.endpoints = endpoints; + } + + FetchResult fetch(String url, Challenge2State state, URI importUri) { + URI current = parse(url); + String lessonRoot = importUri.getPath().substring(0, importUri.getPath().length() - IMPORT_PATH.length()); + requireApprovedSource(current, importUri, lessonRoot); + + for (int redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { + if (hasDifferentOrigin(current, importUri)) { + throw new IllegalArgumentException("This lesson cannot fetch that resource"); + } + String path = current.getPath(); + if (path.equals(lessonRoot + FEED_PATH)) { + return new FetchResult(endpoints.feeds(), redirects, false); + } + if (path.equals(lessonRoot + REDIRECT_PATH)) { + if (redirects == MAX_REDIRECTS) { + throw new IllegalArgumentException("Too many redirects"); + } + // The source path is checked once. Redirect targets intentionally bypass that check. + current = followRedirect(current); + } else if (path.equals(lessonRoot + INTERNAL_PATH)) { + ResponseEntity> response = diagnostic.diagnostics(state.internalToken()); + return new FetchResult(response.getBody().get("flag"), redirects, true); + } else { + throw new IllegalArgumentException("This lesson cannot fetch that resource"); + } + } + throw new IllegalArgumentException("Too many redirects"); + } + + private void requireApprovedSource(URI source, URI importUri, String lessonRoot) { + String path = source.getPath(); + if (hasDifferentOrigin(source, importUri) + || !(path.equals(lessonRoot + FEED_PATH) || path.equals(lessonRoot + REDIRECT_PATH))) { + throw new IllegalArgumentException("The starting URL is not an approved feed source"); + } + } + + private URI followRedirect(URI current) { + String destination = + UriComponentsBuilder.fromUri(current).build().getQueryParams().getFirst("url"); + if (destination == null) { + throw new IllegalArgumentException("The redirect needs a destination"); + } + ResponseEntity response = + endpoints.redirect(URLDecoder.decode(destination, StandardCharsets.UTF_8)); + if (!response.getStatusCode().is3xxRedirection() + || response.getHeaders().getLocation() == null) { + throw new IllegalArgumentException("The redirect has no valid destination"); + } + return parse(current.resolve(response.getHeaders().getLocation()).toString()); + } + + private boolean hasDifferentOrigin(URI candidate, URI request) { + return !candidate.getScheme().equalsIgnoreCase(request.getScheme()) + || !candidate.getHost().equalsIgnoreCase(request.getHost()) + || candidate.getPort() != request.getPort(); + } + + private URI parse(String url) { + try { + if (url == null || url.length() > MAX_URL_LENGTH) { + throw new IllegalArgumentException("Invalid lesson URL"); + } + URI uri = URI.create(url); + if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getFragment() != null) { + throw new IllegalArgumentException("Invalid lesson URL"); + } + return uri; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid lesson URL", e); + } + } + + record FetchResult(String body, int redirects, boolean reachedInternal) {} +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedXmlService.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedXmlService.java new file mode 100644 index 00000000000..299a5161621 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/ChainedXmlService.java @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import java.io.StringReader; +import java.net.URI; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +@Service +public class ChainedXmlService { + + private static final int MAX_XML_LENGTH = 4096; + private static final int MAX_EXTERNAL_ENTITIES = 4; + + private final ChainedFetchService fetchService; + + public ChainedXmlService(ChainedFetchService fetchService) { + this.fetchService = fetchService; + } + + String parse(String xml, Challenge2State state, URI importUri) { + if (xml == null || xml.length() > MAX_XML_LENGTH) { + throw new IllegalArgumentException("XML must be at most 4096 characters"); + } + try { + AtomicBoolean reachedInternalThroughRedirect = new AtomicBoolean(); + InputSource source = new InputSource(new StringReader(xml)); + source.setSystemId(importUri.toString()); + Document document = newDocumentBuilder(state, importUri, reachedInternalThroughRedirect).parse(source); + if (!"feed".equals(document.getDocumentElement().getTagName())) { + throw new IllegalArgumentException("Expected a feed document"); + } + if (reachedInternalThroughRedirect.get()) { + state.recordXmlChain(); + } + return document.getDocumentElement().getTextContent().strip(); + } catch (Exception e) { + throw new IllegalArgumentException("XML could not be processed within this lesson", e); + } + } + + private DocumentBuilder newDocumentBuilder( + Challenge2State state, URI importUri, AtomicBoolean reachedInternalThroughRedirect) + throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setXIncludeAware(false); + factory.setFeature("http://xml.org/sax/features/external-general-entities", true); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + + DocumentBuilder builder = factory.newDocumentBuilder(); + AtomicInteger entityCount = new AtomicInteger(); + builder.setEntityResolver( + (publicId, systemId) -> + resolveEntity(systemId, state, importUri, entityCount, reachedInternalThroughRedirect)); + return builder; + } + + private InputSource resolveEntity( + String systemId, + Challenge2State state, + URI importUri, + AtomicInteger entityCount, + AtomicBoolean reachedInternalThroughRedirect) + throws SAXException { + if (entityCount.incrementAndGet() > MAX_EXTERNAL_ENTITIES) { + throw new SAXException("Too many external entities"); + } + if (systemId == null) { + throw new SAXException("External entity needs a lesson URL"); + } + + ChainedFetchService.FetchResult result; + try { + result = fetchService.fetch(importUri.resolve(systemId).toString(), state, importUri); + } catch (IllegalArgumentException e) { + throw new SAXException("External entity is outside this lesson", e); + } + if (result.reachedInternal() && result.redirects() > 0) { + reachedInternalThroughRedirect.set(true); + } + return new InputSource(new StringReader(result.body())); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2.java new file mode 100644 index 00000000000..8f9de0e1ad1 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import org.owasp.webgoat.container.lessons.Category; +import org.owasp.webgoat.container.lessons.Lesson; +import org.springframework.stereotype.Component; + +@Component +public class Challenge2 extends Lesson { + + @Override + public Category getDefaultCategory() { + return Category.CHALLENGE; + } + + @Override + public String getTitle() { + return "challenge2.title"; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2State.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2State.java new file mode 100644 index 00000000000..7387eee81d0 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2State.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import java.io.Serial; +import java.io.Serializable; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.SessionScope; + +@Component +@SessionScope +public class Challenge2State implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private final String flag; + private final String internalToken; + private boolean reachedThroughXml; + + public Challenge2State() { + byte[] random = new byte[16]; + new SecureRandom().nextBytes(random); + flag = UUID.randomUUID().toString(); + internalToken = HexFormat.of().formatHex(random); + } + + String flag() { + return flag; + } + + String internalToken() { + return internalToken; + } + + synchronized void recordXmlChain() { + reachedThroughXml = true; + } + + synchronized boolean completedXmlChain() { + return reachedThroughXml; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/FeedImportEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/FeedImportEndpoint.java new file mode 100644 index 00000000000..257d52a5a44 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/FeedImportEndpoint.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class FeedImportEndpoint { + + private final ChainedXmlService xmlService; + private final Challenge2State state; + + public FeedImportEndpoint(ChainedXmlService xmlService, Challenge2State state) { + this.xmlService = xmlService; + this.state = state; + } + + @PostMapping("/northstar/import") + public ResponseEntity> importFeed( + @RequestParam String xml, HttpServletRequest request) { + try { + URI importUri = URI.create(request.getRequestURL().toString()); + return ResponseEntity.ok(Map.of("preview", xmlService.parse(xml, state, importUri))); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/InternalDiagnostic.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/InternalDiagnostic.java new file mode 100644 index 00000000000..4246b6fccf3 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/InternalDiagnostic.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class InternalDiagnostic { + + static final String INTERNAL_HEADER = "X-WebGoat-Lesson-Internal"; + + private final Challenge2State state; + + public InternalDiagnostic(Challenge2State state) { + this.state = state; + } + + @GetMapping("/northstar/internal/diagnostics") + public ResponseEntity> diagnostics( + @RequestHeader(value = INTERNAL_HEADER, required = false) String token) { + // This token models service identity without relying on network addresses or proxy headers. + if (!state.internalToken().equals(token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return ResponseEntity.ok(Map.of("status", "UP", "flag", state.flag())); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/NorthstarEndpoints.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/NorthstarEndpoints.java new file mode 100644 index 00000000000..8640a8b7c7c --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge2/NorthstarEndpoints.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NorthstarEndpoints { + + @GetMapping("/northstar/go") + public ResponseEntity redirect(@RequestParam String url) { + try { + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @GetMapping("/northstar/feeds") + public String feeds() { + return "Partner bulletin: the evening edition is ready for review."; + } +} diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index a49bd2dbd84..e6a9bd6d0f2 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -42,7 +42,7 @@ category.a8=(A8) Software & Data Integrity category.a9=(A9) Security Logging Failures category.a10=(A10) Server-side Request Forgery category.client.side=Client side -category.challenge=Challenges +category.challenge=Security exercises not.empty=This field is required. diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties index 063c0b0f8a5..00470edeb1b 100644 --- a/src/main/resources/i18n/messages_de.properties +++ b/src/main/resources/i18n/messages_de.properties @@ -24,6 +24,6 @@ category.a8=(A8) Daten- und Softwareintegrit\u00e4t category.a9=(A9) Unzureichendes Logging category.a10=(A10) Serverseitige Anfragef\u00e4lschung category.client.side=Clientseitig -category.challenge=Herausforderungen +category.challenge=Sicherheits\u00fcbungen mailbox=Postfach diff --git a/src/main/resources/i18n/messages_es.properties b/src/main/resources/i18n/messages_es.properties index 809886b2245..3a19bbd6171 100644 --- a/src/main/resources/i18n/messages_es.properties +++ b/src/main/resources/i18n/messages_es.properties @@ -43,7 +43,7 @@ category.a8=(A8) Fallos de Integridad category.a9=(A9) Fallos de Auditoría category.a10=(A10) Falsificación (SSRF) category.client.side=Lado del cliente -category.challenge=Desafíos +category.challenge=Ejercicios de seguridad not.empty=Este campo es obligatorio. diff --git a/src/main/resources/i18n/messages_fr.properties b/src/main/resources/i18n/messages_fr.properties index c6abf3a03ca..fbab8910001 100644 --- a/src/main/resources/i18n/messages_fr.properties +++ b/src/main/resources/i18n/messages_fr.properties @@ -16,6 +16,6 @@ category.a8=(A8) Int\u00e9grit\u00e9 des logiciels et des donn\u00e9es category.a9=(A9) Journalisation insuffisante category.a10=(A10) Falsification de requ\u00eate c\u00f4t\u00e9 serveur category.client.side=C\u00f4t\u00e9 client -category.challenge=D\u00e9fis +category.challenge=Exercices de s\u00e9curit\u00e9 mailbox=Bo\u00eete de r\u00e9ception diff --git a/src/main/resources/i18n/messages_nl.properties b/src/main/resources/i18n/messages_nl.properties index 53dfce17e8f..d7e65f37bed 100644 --- a/src/main/resources/i18n/messages_nl.properties +++ b/src/main/resources/i18n/messages_nl.properties @@ -38,7 +38,7 @@ category.a8=(A8) Software & Data-integriteit category.a9=(A9) Tekortkomingen in Logging category.a10=(A10) Serverside Request Vervalsing category.client.side=Client-kant -category.challenge=Uitdagingen +category.challenge=Beveiligingsoefeningen sign.up=Registreer register.title=Aanmelden als nieuwe gebruiker register.new=of aanmelden als nieuwe gebruiker diff --git a/src/main/resources/lessons/challenges/css/challenge-flag.css b/src/main/resources/lessons/challenges/css/challenge-flag.css new file mode 100644 index 00000000000..28bd005c98f --- /dev/null +++ b/src/main/resources/lessons/challenges/css/challenge-flag.css @@ -0,0 +1,7 @@ +.challenge-flag-form .input-group-addon { + padding: 6px 9px; +} + +.challenge-flag-form .fa-flag-checkered { + font-size: 16px; +} diff --git a/src/main/resources/lessons/challenges/css/challenge2.css b/src/main/resources/lessons/challenges/css/challenge2.css new file mode 100644 index 00000000000..023b6eaf1d3 --- /dev/null +++ b/src/main/resources/lessons/challenges/css/challenge2.css @@ -0,0 +1,62 @@ +.partner-feed-dashboard { + width: 100%; + max-width: none; +} + +.partner-feed-kicker { + color: #526579; + font-size: 0.85rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.partner-feed-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.partner-feed-card { + border: 1px solid #d5dde5; + border-radius: 8px; + background: #fff; + padding: 1.25rem; +} + +.partner-feed-card code { + display: block; + background: #f4f7fa; + border: 1px solid #dce5ed; + border-radius: 4px; + overflow-wrap: anywhere; + margin-bottom: 1rem; + padding: 0.5rem 0.75rem; + white-space: normal; +} + +.partner-feed-import textarea { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + margin: 0.5rem 0 1rem; +} + +.partner-feed-preview { + background: #f4f7fa; + border-radius: 6px; + margin-top: 1rem; + padding: 0.75rem 1rem; +} + +.partner-feed-preview pre { + margin: 0.5rem 0 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.partner-feed-submit { + margin-top: 1.5rem; +} + +.partner-feed-flag-submit { + margin-top: 10px; +} diff --git a/src/main/resources/lessons/challenges/documentation/Challenge_introduction.adoc b/src/main/resources/lessons/challenges/documentation/Challenge_introduction.adoc index cd23abfe794..d9762d13b88 100644 --- a/src/main/resources/lessons/challenges/documentation/Challenge_introduction.adoc +++ b/src/main/resources/lessons/challenges/documentation/Challenge_introduction.adoc @@ -1,29 +1,26 @@ -=== Welcome to the WebGoat challenge (CTF) +=== Introduction -==== Introduction +These exercises build on WebGoat's focused lessons. Those lessons teach one security concept at a time. This section +asks you to combine concepts in a larger application and use them together to reach an objective. -The challenges contain more a CTF like lessons where we do not provide any explanations what you need to do, no hints -will be provided. You can use these challenges in a CTF style where you can run WebGoat on one server and all -participants can join and hack the challenges. +The exercise gives you an outcome without naming the vulnerability or the steps needed to reach it. You decide which +parts of the application to inspect, where its trust boundaries are, and how separate weaknesses can form one attack +path. The available help is limited. Use the focused lessons as reference material when you need to revisit a concept, +then investigate the scenario yourself. -:hardbreaks: -In this CTF you will need to solve a couple of challenges, each challenge will give you a flag which you will -need to post in order to gain points. +Browser tools, scripts, intercepting proxies, and AI assistants are valid tools for this work. An assistant may produce +a payload quickly, but you still need to understand why it works, explain the complete attack chain, and determine +whether a proposed repair removes the underlying weakness. -Flags have the following format: `a7179f89-906b-4fec-9d99-f15b796e7208` +==== Working with the exercises -==== Rules +- Use the focused lessons when you need background on a security concept. -- Do not try to hack the competition infrastructure. If you happen to find a bug or vulnerability please send us -an e-mail. +- Keep your work within the WebGoat application. Do not target other systems or learners. -- Play fair, do not try sabotage other competing teams, or in any way hindering the progress of another team. +- Document the components, trust boundaries, and assumptions that make the attack possible. -- Brute forcing of challenges / flags is not allowed. - -:hardbreaks: -*Have fun!!* -Team WebGoat +*Understand how the system behaves, then verify what changes.* image::images/boss.jpg[] diff --git a/src/main/resources/lessons/challenges/documentation/Challenge_introduction_es.adoc b/src/main/resources/lessons/challenges/documentation/Challenge_introduction_es.adoc index db29de90ab3..4ac6433f1e9 100644 --- a/src/main/resources/lessons/challenges/documentation/Challenge_introduction_es.adoc +++ b/src/main/resources/lessons/challenges/documentation/Challenge_introduction_es.adoc @@ -1,24 +1,25 @@ -=== Bienvenido al desafío WebGoat (CTF) +=== Introducción -==== Introducción +Estos ejercicios se basan en las lecciones centradas de WebGoat. Esas lecciones enseñan un concepto de seguridad cada vez. +Esta sección te pide combinar conceptos en una aplicación más grande y utilizarlos juntos para alcanzar un objetivo. -Los desafíos contienen lecciones más parecidas a un CTF, donde no proporcionamos explicaciones sobre lo que debes hacer y no se darán pistas. Puedes utilizar estos desafíos en un estilo CTF, donde puedes ejecutar WebGoat en un servidor y todos los participantes pueden unirse y hackear los desafíos. Hay un marcador disponible en el link:scoreboard["scoreboard",window=_blank] +El ejercicio te da un resultado sin indicar qué vulnerabilidad debes utilizar ni los pasos necesarios para alcanzarlo. +Decidirás qué partes de la aplicación inspeccionar, dónde están sus límites de confianza y cómo varias debilidades pueden +formar una sola ruta de ataque. La ayuda disponible es limitada. Utiliza las lecciones centradas como material de consulta +cuando necesites repasar un concepto y luego investiga el escenario por tu cuenta. -:hardbreaks: -En este CTF, deberás resolver varios desafíos; cada uno te proporcionará una bandera que deberás enviar para obtener puntos. +Las herramientas del navegador, los scripts, los proxies de interceptación y los asistentes de IA son herramientas válidas. +Un asistente puede producir un payload rápidamente, pero aún debes comprender por qué funciona, explicar toda la cadena de +ataque y determinar si una reparación propuesta elimina la debilidad subyacente. -Las banderas tienen el siguiente formato: `a7179f89-906b-4fec-9d99-f15b796e7208` +==== Cómo trabajar con los ejercicios -==== Reglas +- Utiliza las lecciones centradas cuando necesites conocer un concepto de seguridad. -- No intentes hackear la infraestructura de la competición. Si descubres algún error o vulnerabilidad, por favor, envíanos un correo electrónico. +- Mantén tu trabajo dentro de la aplicación WebGoat. No ataques otros sistemas ni a otros estudiantes. -- Juega limpio, no intentes sabotear a otros equipos competidores ni obstaculizar de ninguna manera el progreso de otro equipo. +- Documenta los componentes, los límites de confianza y las suposiciones que hacen posible el ataque. -- No se permite el uso de fuerza bruta en desafíos/flags. - -:hardbreaks: -*¡Diviértete!* -El equipo WebGoat +*Comprende cómo funciona el sistema y verifica los cambios.* image::images/boss.jpg[] diff --git a/src/main/resources/lessons/challenges/html/Challenge1.html b/src/main/resources/lessons/challenges/html/Challenge1.html index eb0a2359c6f..66d780a4d43 100644 --- a/src/main/resources/lessons/challenges/html/Challenge1.html +++ b/src/main/resources/lessons/challenges/html/Challenge1.html @@ -3,6 +3,7 @@
+
@@ -34,16 +35,15 @@
-
+
-
+
- +
diff --git a/src/main/resources/lessons/challenges/html/Challenge2.html b/src/main/resources/lessons/challenges/html/Challenge2.html new file mode 100644 index 00000000000..53c350b1d38 --- /dev/null +++ b/src/main/resources/lessons/challenges/html/Challenge2.html @@ -0,0 +1,67 @@ + + + + + + +
+
+
+

Northstar Press · syndication desk

+

Partner feed recovery

+

An incident locked the evening edition. Recover the value held by the diagnostic service and submit it as evidence below.

+
+ +
+
+

Incident note

+

The diagnostic service is not available from the browser.

+

Open diagnostic service

+ +
+ +
+

Partner directory

+

Approved feed source for the importer:

+

Open partner bulletins

+ +

Open the publisher website

+
+
+ +
+

Import a partner feed

+

Preview an XML feed before adding its stories to the edition.

+ + + + + +
+ Preview +
No feed imported yet.
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + + diff --git a/src/main/resources/lessons/challenges/html/Challenge5.html b/src/main/resources/lessons/challenges/html/Challenge5.html index 07df7bf802d..4cb0cf44b2c 100644 --- a/src/main/resources/lessons/challenges/html/Challenge5.html +++ b/src/main/resources/lessons/challenges/html/Challenge5.html @@ -4,6 +4,7 @@
+
@@ -66,16 +67,15 @@

-
+
-
+
- +
diff --git a/src/main/resources/lessons/challenges/html/Challenge6.html b/src/main/resources/lessons/challenges/html/Challenge6.html index 90966349aaa..030bf8d1cb1 100644 --- a/src/main/resources/lessons/challenges/html/Challenge6.html +++ b/src/main/resources/lessons/challenges/html/Challenge6.html @@ -4,6 +4,7 @@
+
@@ -99,16 +100,15 @@

- +
-
+
- +
diff --git a/src/main/resources/lessons/challenges/html/Challenge7.html b/src/main/resources/lessons/challenges/html/Challenge7.html index a6d113b6ee1..c1cedff75f6 100644 --- a/src/main/resources/lessons/challenges/html/Challenge7.html +++ b/src/main/resources/lessons/challenges/html/Challenge7.html @@ -12,6 +12,7 @@
+
@@ -57,16 +58,15 @@

Forgot Password?


- +
-
+
- +
diff --git a/src/main/resources/lessons/challenges/html/Challenge8.html b/src/main/resources/lessons/challenges/html/Challenge8.html index 5900024a7a8..7806344b5b0 100644 --- a/src/main/resources/lessons/challenges/html/Challenge8.html +++ b/src/main/resources/lessons/challenges/html/Challenge8.html @@ -3,6 +3,7 @@
+
@@ -154,7 +155,7 @@

Rating breakdown

WebGoat rocks!
This is a great tool to learn about security - and have some fun with a couple challenges. + and have some fun with a couple security exercises.
@@ -231,16 +232,15 @@

Rating breakdown


- +
-
+
- +
diff --git a/src/main/resources/lessons/challenges/i18n/WebGoatLabels.properties b/src/main/resources/lessons/challenges/i18n/WebGoatLabels.properties index 8a3dffd716d..ebf720f407c 100644 --- a/src/main/resources/lessons/challenges/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/challenges/i18n/WebGoatLabels.properties @@ -1,13 +1,13 @@ -challenge0.title=WebGoat Challenge +challenge0.title=WebGoat security exercises challenge1.title=Admin lost password -challenge2.title=Get it for free +challenge2.title=Partner feed recovery challenge3.title=Photo comments challenge5.title=Without password challenge6.title=Creating a new account challenge7.title=Admin password reset challenge8.title=Without account challenge9.title=Changing password -challenge.solved=Congratulations, you solved the challenge. Here is your flag: {0} +challenge.solved=Congratulations, you completed the exercise. Here is the result: {0} challenge.close=This is not the correct password for Larry, please try again. email.send=An e-mail has been sent to {0} @@ -16,8 +16,14 @@ user.exists=User {0} already exists please try to register with a different user user.created=User {0} created, please proceed to the login page. input.invalid=Input for user, email and/or password is empty or too long, please fill in all field and/or limit all fields to 30 characters. -challenge.flag.correct=Congratulations you have solved the challenge!! -challenge.flag.incorrect=Sorry this is not the correct flag, please try again. +challenge.flag.correct=Congratulations, you completed the exercise. +challenge.flag.incorrect=That result is not correct, please try again. + +challenge2.chain.success=Result accepted. The feed importer reached the diagnostic service through the publisher redirect. +challenge2.chain.failure=That result is not linked to a completed import in this session. +challenge2.chain.hint1=Inspect what the feed importer does with an XML document that refers to another resource. +challenge2.chain.hint2=The importer accepts the bulletin URL. Look closely at the publisher link. +challenge2.chain.hint3=Try an external entity that starts at the approved source and reaches diagnostics after a redirect. ip.address.unknown=IP address unknown, e-mail has been sent. diff --git a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_de.properties b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_de.properties index 1b92eae19eb..54e212ef071 100644 --- a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_de.properties +++ b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_de.properties @@ -1,6 +1,6 @@ -challenge0.title=WebGoat Herausforderung +challenge0.title=WebGoat-Sicherheitsübungen challenge1.title=Admin-Passwort vergessen -challenge2.title=Kostenlos erhalten +challenge2.title=Wiederherstellung des Partner-Feeds challenge3.title=Foto-Kommentare challenge5.title=Ohne Passwort challenge6.title=Neues Konto erstellen diff --git a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_fr.properties b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_fr.properties index c1c4164b3fb..4db2ce94afe 100644 --- a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_fr.properties +++ b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_fr.properties @@ -1,6 +1,6 @@ -challenge0.title=D\u00e9fi WebGoat +challenge0.title=Exercices de s\u00e9curit\u00e9 WebGoat challenge1.title=Mot de passe admin perdu -challenge2.title=Obtenir gratuitement +challenge2.title=R\u00e9cup\u00e9ration du flux partenaire challenge3.title=Commentaires photo challenge5.title=Sans mot de passe challenge6.title=Cr\u00e9er un nouveau compte diff --git a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_nl.properties b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_nl.properties index 1dff0f64bc3..57d2b69ad14 100644 --- a/src/main/resources/lessons/challenges/i18n/WebGoatLabels_nl.properties +++ b/src/main/resources/lessons/challenges/i18n/WebGoatLabels_nl.properties @@ -1,6 +1,6 @@ -challenge0.title=WebGoat uitdaging +challenge0.title=WebGoat-beveiligingsoefeningen challenge1.title=Admin wachtwoord kwijt -challenge2.title=Gratis verkrijgen +challenge2.title=Herstel van de partnerfeed challenge3.title=Foto reacties challenge5.title=Zonder wachtwoord challenge6.title=Nieuw account aanmaken diff --git a/src/main/resources/lessons/challenges/js/challenge2.js b/src/main/resources/lessons/challenges/js/challenge2.js new file mode 100644 index 00000000000..521bc3ea790 --- /dev/null +++ b/src/main/resources/lessons/challenges/js/challenge2.js @@ -0,0 +1,26 @@ +(function () { + "use strict"; + + const importForm = document.getElementById("partner-feed-import"); + if (!importForm) return; + + document.getElementById("partner-feed-bulletin-url").textContent = + document.getElementById("partner-feed-bulletin-link").href; + document.getElementById("partner-feed-diagnostic-url").textContent = + document.getElementById("partner-feed-diagnostic-link").href; + + importForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const preview = document.getElementById("partner-feed-preview"); + try { + const response = await fetch(importForm.action, { + method: "POST", + body: new FormData(importForm), + }); + const result = await response.json(); + preview.textContent = response.ok ? result.preview : result.error; + } catch (error) { + preview.textContent = `Import failed: ${error.message}`; + } + }); +})(); diff --git a/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java b/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java index e70003cc9d2..696e39ad791 100644 --- a/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java @@ -35,7 +35,7 @@ void success() throws Exception { "password", SolutionConstants.PASSWORD.replace( "1234", String.format("%04d", ImageServlet.PINCODE)))) - .andExpect(jsonPath("$.feedback", CoreMatchers.containsString("flag: " + flags.getFlag(1)))) + .andExpect(jsonPath("$.feedback", CoreMatchers.containsString("result: " + flags.getFlag(1)))) .andExpect(jsonPath("$.lessonCompleted", CoreMatchers.is(true))); } diff --git a/src/test/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2Test.java b/src/test/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2Test.java new file mode 100644 index 00000000000..8767e1a040b --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/challenges/challenge2/Challenge2Test.java @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.challenges.challenge2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.lessons.Assignment; +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; + +class Challenge2Test extends LessonTest { + + private static final URI IMPORT_URI = + URI.create("http://localhost:8080/WebGoat/northstar/import"); + + @Autowired private Course course; + + @Test + void challengeHasOneAssignmentAndRendersTheDashboard() throws Exception { + var lesson = course.getLessonByName(new LessonName("Challenge2")); + assertThat(lesson.getPackage()).isEqualTo("challenges"); + assertThat(lesson.getTitle()).isEqualTo("challenge2.title"); + assertThat(lesson.getAssignments()) + .extracting(Assignment::getName) + .containsExactly("Assignment2"); + + mockMvc + .perform(get("/Challenge2.lesson.lesson")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("

Partner feed recovery

"))) + .andExpect(content().string(containsString("Import a partner feed"))) + .andExpect(content().string(containsString("/northstar/feeds"))) + .andExpect(content().string(containsString("/northstar/import"))) + .andExpect(content().string(containsString("/northstar/recovery"))) + .andExpect(content().string(containsString("fa-flag-checkered"))) + .andExpect(content().string(containsString("Submit result"))); + } + + @Test + void directDiagnosticRequestAndForgedHeaderAreUnauthorized() throws Exception { + mockMvc + .perform(get("/northstar/internal/diagnostics")) + .andExpect(status().isUnauthorized()); + mockMvc + .perform( + get("/northstar/internal/diagnostics") + .header(InternalDiagnostic.INTERNAL_HEADER, "true")) + .andExpect(status().isUnauthorized()); + } + + @Test + void publisherLinkIsAnOpenRedirect() throws Exception { + mockMvc + .perform(get("/northstar/go").param("url", "https://example.org")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://example.org")); + } + + @Test + void partnerBulletinLinkOpensInWebGoat() throws Exception { + mockMvc + .perform(get("/northstar/feeds")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Partner bulletin:"))); + } + + @Test + void ordinaryFeedImportReturnsAPreview() throws Exception { + mockMvc + .perform( + post("/northstar/import") + .param("xml", "Evening bulletin")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.preview").value("Evening bulletin")); + } + + @Test + void xmlEntityMustPassThroughThePublisherRedirect() { + Challenge2State state = new Challenge2State(); + ChainedFetchService fetcher = fetcher(state); + ChainedXmlService xmlService = new ChainedXmlService(fetcher); + String internal = IMPORT_URI.resolve("internal/diagnostics").toString(); + String redirect = redirectTo(internal); + + assertThatThrownBy(() -> fetcher.fetch(internal, state, IMPORT_URI)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(fetcher.fetch(redirect, state, IMPORT_URI).body()).isEqualTo(state.flag()); + assertThat(state.completedXmlChain()).isFalse(); + + String xml = entityFeed(redirect); + assertThat(xmlService.parse(xml, state, IMPORT_URI)).isEqualTo(state.flag()); + assertThat(state.completedXmlChain()).isTrue(); + } + + @Test + void relativeEntityAndRedirectTargetsWorkBehindAnyContextPath() { + Challenge2State state = new Challenge2State(); + ChainedXmlService xmlService = new ChainedXmlService(fetcher(state)); + + assertThat(xmlService.parse(entityFeed("go?url=internal%2Fdiagnostics"), state, IMPORT_URI)) + .isEqualTo(state.flag()); + assertThat(state.completedXmlChain()).isTrue(); + } + + @Test + void parserRejectsFilesAndArbitraryHostsWithoutMarkingTheChallenge() { + Challenge2State state = new Challenge2State(); + ChainedXmlService xmlService = new ChainedXmlService(fetcher(state)); + + assertThatThrownBy(() -> xmlService.parse(entityFeed("file:///etc/passwd"), state, IMPORT_URI)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> xmlService.parse(entityFeed("http://example.org/"), state, IMPORT_URI)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(state.completedXmlChain()).isFalse(); + } + + @Test + void malformedXmlDoesNotMarkTheChallengeEvenAfterTheInternalFetch() { + Challenge2State state = new Challenge2State(); + ChainedXmlService xmlService = new ChainedXmlService(fetcher(state)); + String xml = + "]>&flag;"; + + assertThatThrownBy(() -> xmlService.parse(xml, state, IMPORT_URI)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(state.completedXmlChain()).isFalse(); + } + + @Test + void redirectDepthIsBounded() { + Challenge2State state = new Challenge2State(); + String url = IMPORT_URI.resolve("feeds").toString(); + for (int i = 0; i < 5; i++) { + url = redirectTo(url); + } + String nestedRedirect = url; + + assertThatThrownBy(() -> fetcher(state).fetch(nestedRedirect, state, IMPORT_URI)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Too many redirects"); + } + + @Test + void sessionFlagsAndInternalTokensAreIndependent() { + Challenge2State first = new Challenge2State(); + Challenge2State second = new Challenge2State(); + + assertThat(first.flag()).matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}"); + assertThat(first.flag()).isNotEqualTo(second.flag()); + assertThat(first.internalToken()).isNotEqualTo(second.internalToken()); + assertThat(new InternalDiagnostic(second).diagnostics(first.internalToken()).getStatusCode().value()) + .isEqualTo(401); + } + + private ChainedFetchService fetcher(Challenge2State state) { + return new ChainedFetchService(new InternalDiagnostic(state), new NorthstarEndpoints()); + } + + private String redirectTo(String destination) { + return IMPORT_URI.resolve( + "go?url=" + URLEncoder.encode(destination, StandardCharsets.UTF_8)) + .toString(); + } + + private String entityFeed(String url) { + return "]>&flag;"; + } +} From cbc3bb0929cefe8ba22046d781b9262e4e65b287 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 11 Oct 2026 13:08:44 +0100 Subject: [PATCH 3/6] fix(sql-injection): mark registration assignment solved Successful registration currently leaves the advanced SQL injection lesson incomplete, preventing learners from reaching full progress. Closes: #2575 --- .../SqlInjectionAdvancedIntegrationTest.java | 17 +++++++++++------ .../advanced/SqlInjectionChallenge.java | 4 +--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/it/java/org/owasp/webgoat/integration/SqlInjectionAdvancedIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/SqlInjectionAdvancedIntegrationTest.java index 52e04ada206..7461287545d 100644 --- a/src/it/java/org/owasp/webgoat/integration/SqlInjectionAdvancedIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/SqlInjectionAdvancedIntegrationTest.java @@ -19,27 +19,30 @@ public void runTests() { params.put("password_reg", "password"); params.put("email_reg", "someone@microsoft.com"); params.put("confirm_password", "password"); - checkAssignmentWithPUT(webGoatUrlConfig.url("SqlInjectionAdvanced/register"), params, false); + checkAssignmentWithPUT(webGoatUrlConfig.url("SqlInjectionAdvanced/register"), params, false); + + params.put("username_reg", "tom' AND substring(password,1,1)='a"); + checkAssignmentWithPUT(webGoatUrlConfig.url("SqlInjectionAdvanced/register"), params, true); params.clear(); params.put("username_login", "tom"); params.put("password_login", "thisisasecretfortomonly"); - checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/login"), params, true); + checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/login"), params, true); params.clear(); params.put("userid_6a", "'; SELECT * FROM user_system_data;--"); - checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6a"), params, true); + checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6a"), params, true); params.clear(); params.put( "userid_6a", "Smith' union select userid,user_name, user_name,user_name,password,cookie,userid from" + " user_system_data --"); - checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6a"), params, true); + checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6a"), params, true); params.clear(); params.put("userid_6b", "passW0rD"); - checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6b"), params, true); + checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/attack6b"), params, true); params.clear(); params.put( @@ -57,6 +60,8 @@ public void runTests() { params.put( "question_4_solution", "Solution 4: The database registers 'Robert' ); DROP TABLE Students;--'."); - checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/quiz"), params, true); + checkAssignment(webGoatUrlConfig.url("SqlInjectionAdvanced/quiz"), params, true); + + checkResults("SqlInjectionAdvanced"); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java index 8f8198105d7..ce55eb950f7 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.sqlinjection.advanced; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.sql.Connection; @@ -70,8 +69,7 @@ public AttackResult registerNewUser( preparedStatement.setString(2, email); preparedStatement.setString(3, password); preparedStatement.execute(); - attackResult = - informationMessage(this).feedback("user.created").feedbackArgs(username).build(); + attackResult = success(this).feedback("user.created").feedbackArgs(username).build(); } } catch (SQLException e) { attackResult = failed(this).output("Something went wrong").build(); From 2d57cc9d55056378467b4e9d4d4ffe81b4f7a511 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 11 Oct 2026 13:21:51 +0100 Subject: [PATCH 4/6] fix(progress): allow users to share lesson assignments A unique progress constraint prevented later users from completing CSRF assignments and returned HTTP 500 when they solved the same challenge. Closes: #2573 --- .../MultiUserProgressIntegrationTest.java | 41 +++++++++ .../container/users/AssignmentProgress.java | 4 +- .../V3__allow_shared_assignments.sql | 13 +++ .../AssignmentProgressMigrationTest.java | 83 +++++++++++++++++++ .../users/UserProgressRepositoryTest.java | 17 ++++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/it/java/org/owasp/webgoat/integration/MultiUserProgressIntegrationTest.java create mode 100644 src/main/resources/db/container/V3__allow_shared_assignments.sql create mode 100644 src/test/java/org/owasp/webgoat/container/AssignmentProgressMigrationTest.java diff --git a/src/it/java/org/owasp/webgoat/integration/MultiUserProgressIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/MultiUserProgressIntegrationTest.java new file mode 100644 index 00000000000..afd5a1e28f0 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/integration/MultiUserProgressIntegrationTest.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +public class MultiUserProgressIntegrationTest extends IntegrationTest { + + @Test + void twoUsersCanSolveTheSameCsrfAssignment() { + String runId = UUID.randomUUID().toString().substring(0, 8); + + for (String username : List.of("progress-" + runId + "-a", "progress-" + runId + "-b")) { + login(username); + startLesson("CSRF"); + + boolean solved = + RestAssured.given() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .header("Referer", "https://example.invalid/form") + .contentType(ContentType.TEXT) + .body("{}") + .post(webGoatUrlConfig.url("csrf/feedback/message")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"); + + assertThat(solved).isTrue(); + } + } +} diff --git a/src/main/java/org/owasp/webgoat/container/users/AssignmentProgress.java b/src/main/java/org/owasp/webgoat/container/users/AssignmentProgress.java index 523bc3b1454..ba614d1e910 100644 --- a/src/main/java/org/owasp/webgoat/container/users/AssignmentProgress.java +++ b/src/main/java/org/owasp/webgoat/container/users/AssignmentProgress.java @@ -9,7 +9,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToOne; +import jakarta.persistence.ManyToOne; import lombok.EqualsAndHashCode; import lombok.Getter; import org.owasp.webgoat.container.lessons.Assignment; @@ -24,7 +24,7 @@ public class AssignmentProgress { private Long id; @Getter - @OneToOne(cascade = CascadeType.ALL) + @ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private Assignment assignment; @Getter private boolean solved; diff --git a/src/main/resources/db/container/V3__allow_shared_assignments.sql b/src/main/resources/db/container/V3__allow_shared_assignments.sql new file mode 100644 index 00000000000..7727967eaff --- /dev/null +++ b/src/main/resources/db/container/V3__allow_shared_assignments.sql @@ -0,0 +1,13 @@ +ALTER TABLE CONTAINER.assignment_progress ADD COLUMN assignment_id_new BIGINT; + +UPDATE CONTAINER.assignment_progress +SET assignment_id_new = assignment_id; + +ALTER TABLE CONTAINER.assignment_progress DROP COLUMN assignment_id CASCADE; + +ALTER TABLE CONTAINER.assignment_progress + ALTER COLUMN assignment_id_new RENAME TO assignment_id; + +ALTER TABLE CONTAINER.assignment_progress + ADD CONSTRAINT FK7o6abukma83ku3xrge9sy0qnr + FOREIGN KEY (assignment_id) REFERENCES CONTAINER.assignment; diff --git a/src/test/java/org/owasp/webgoat/container/AssignmentProgressMigrationTest.java b/src/test/java/org/owasp/webgoat/container/AssignmentProgressMigrationTest.java new file mode 100644 index 00000000000..e46bc11eff9 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/container/AssignmentProgressMigrationTest.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.container; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +class AssignmentProgressMigrationTest { + + @Test + void existingAssignmentsCanBeUsedByMultipleProgressRows() throws Exception { + var dataSource = + new DriverManagerDataSource("jdbc:hsqldb:mem:" + UUID.randomUUID(), "sa", ""); + dataSource.setDriverClassName("org.hsqldb.jdbc.JDBCDriver"); + + Flyway.configure() + .dataSource(dataSource) + .schemas("container") + .locations("db/container") + .target(MigrationVersion.fromVersion("2")) + .load() + .migrate(); + + long assignmentId; + try (var connection = dataSource.getConnection(); + var statement = + connection.prepareStatement( + "INSERT INTO CONTAINER.assignment (name, path) VALUES (?, ?)", + Statement.RETURN_GENERATED_KEYS)) { + statement.setString(1, "CSRFLogin"); + statement.setString(2, "/csrf/login"); + statement.executeUpdate(); + try (var keys = statement.getGeneratedKeys()) { + assertThat(keys.next()).isTrue(); + assignmentId = keys.getLong(1); + } + try (var progress = + connection.prepareStatement( + "INSERT INTO CONTAINER.assignment_progress (assignment_id, solved) VALUES (?, ?)")) { + progress.setLong(1, assignmentId); + progress.setBoolean(2, true); + progress.executeUpdate(); + } + } + + Flyway.configure() + .dataSource(dataSource) + .schemas("container") + .locations("db/container") + .load() + .migrate(); + + try (var connection = dataSource.getConnection(); + var progress = + connection.prepareStatement( + "INSERT INTO CONTAINER.assignment_progress (assignment_id, solved) VALUES (?, ?)")) { + progress.setLong(1, assignmentId); + progress.setBoolean(2, true); + progress.executeUpdate(); + try (var count = + connection.prepareStatement( + "SELECT COUNT(*) FROM CONTAINER.assignment_progress WHERE assignment_id = ?")) { + count.setLong(1, assignmentId); + try (var rows = count.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getInt(1)).isEqualTo(2); + } + } + progress.setLong(1, assignmentId + 1); + assertThatThrownBy(progress::executeUpdate).isInstanceOf(SQLException.class); + } + } +} diff --git a/src/test/java/org/owasp/webgoat/container/users/UserProgressRepositoryTest.java b/src/test/java/org/owasp/webgoat/container/users/UserProgressRepositoryTest.java index bf2464d2d9d..5aaf2ea12b0 100644 --- a/src/test/java/org/owasp/webgoat/container/users/UserProgressRepositoryTest.java +++ b/src/test/java/org/owasp/webgoat/container/users/UserProgressRepositoryTest.java @@ -72,6 +72,23 @@ void solvedAssignmentsShouldBeSaved() { assertThat(userProgress.numberOfAssignmentsSolved()).isEqualTo(2); } + @Test + void differentUsersCanSolveTheSameAssignment() { + var lesson = new TestLesson(); + var firstUser = new UserProgress("first-user"); + firstUser.assignmentSolved(lesson, "test1"); + userProgressRepository.saveAndFlush(firstUser); + + var secondUser = new UserProgress("second-user"); + secondUser.assignmentSolved(lesson, "test1"); + userProgressRepository.saveAndFlush(secondUser); + + assertThat(userProgressRepository.findByUser("first-user").numberOfAssignmentsSolved()) + .isEqualTo(1); + assertThat(userProgressRepository.findByUser("second-user").numberOfAssignmentsSolved()) + .isEqualTo(1); + } + @Test void saveAndLoadShouldHaveCorrectNumberOfAttempts() { UserProgress userProgress = new UserProgress(USER); From 10cecda8b93f9ea1439188a6257dfb7fc9c954d5 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 11 Oct 2026 13:33:16 +0100 Subject: [PATCH 5/6] fix(challenges): record Challenge 8 completion Submitting the correct flag currently leaves Challenge 8 incomplete, so learners cannot reach full progress in the report card. Closes: #2574 --- .../integration/ChallengeIntegrationTest.java | 22 +++++++++++++++++++ .../lessons/challenges/FlagController.java | 10 ++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java index da3511da374..4c07a422e4f 100644 --- a/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/ChallengeIntegrationTest.java @@ -140,6 +140,28 @@ void testChallenge7() { checkAssignment(webGoatUrlConfig.url("challenge/flag/7"), Map.of("flag", flag), true); } + @Test + void testChallenge8() { + startLesson("Challenge8"); + + String flagHeader = + RestAssured.given() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .head(webGoatUrlConfig.url("challenge/8/vote/5")) + .then() + .statusCode(200) + .extract() + .header("X-FlagController"); + + Assertions.assertThat(flagHeader).startsWith("Thanks for voting, your flag is: "); + String flag = flagHeader.substring(flagHeader.lastIndexOf(": ") + 2); + checkAssignment(webGoatUrlConfig.url("challenge/flag/8"), Map.of("flag", "invalid"), false); + checkAssignment(webGoatUrlConfig.url("challenge/flag/8"), Map.of("flag", flag), true); + + checkResults("Challenge8"); + } + private String extractFlag(String result) { Matcher matcher = FLAG_PATTERN.matcher(result); Assertions.assertThat(matcher.find()).as("challenge response contains a flag").isTrue(); diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java b/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java index cf54315f4fc..addd7548927 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java @@ -9,6 +9,7 @@ import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.lessons.challenges.challenge8.Assignment8; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -19,19 +20,22 @@ public class FlagController implements AssignmentEndpoint { private final Flags flags; + private final Assignment8 assignment8; - public FlagController(Flags flags) { + public FlagController(Flags flags, Assignment8 assignment8) { this.flags = flags; + this.assignment8 = assignment8; } @PostMapping(path = "/challenge/flag/{flagNumber}") @ResponseBody public AttackResult postFlag(@PathVariable int flagNumber, @RequestParam String flag) { var expectedFlag = flags.getFlag(flagNumber); + AssignmentEndpoint assignment = flagNumber == 8 ? assignment8 : this; if (expectedFlag.isCorrect(flag)) { - return success(this).feedback("challenge.flag.correct").build(); + return success(assignment).feedback("challenge.flag.correct").build(); } else { - return failed(this).feedback("challenge.flag.incorrect").build(); + return failed(assignment).feedback("challenge.flag.incorrect").build(); } } } From 7e198f39189f33e1912f7cbb60e24e42635ca06a Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Mon, 12 Oct 2026 07:38:02 +0100 Subject: [PATCH 6/6] chore(release): prepare 2026.5 --- RELEASE_NOTES.md | 33 +++++++++++++++++++++++++++++++++ pom.xml | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e88640f82c7..9651a46ffde 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,38 @@ # WebGoat release notes +## Version 2026.5 + +### New functionality + +- Add an HMAC timing attack lesson (#2548). +- Add an HTTP parameter pollution lesson. +- Add a password hashing lesson covering salts, Argon2id, and peppers. +- Add the partner feed recovery challenge (#2570). +- Expand the command injection exercise with bounded, portable command execution (#1555). + +### Bug fixes + +- Allow multiple users to solve the same CSRF assignments (#2573). +- Record Challenge 8 completion after a correct flag submission (#2574). +- Mark advanced SQL injection registration as solved when it succeeds (#2575). +- Keep WebWolf on its configured port. + +### Technical tasks + +- Run the operating system CI builds in parallel and add issue triage for likely lesson vulnerabilities (#2569). +- Make XXE integration tests use controlled entity targets (#2547). +- Cover SQL mitigation form submissions in integration tests (#2502). +- Update dependencies and document the HMAC timing attack solutions (#2577). + +### Contributors + +- Nanne Baars +- Dependabot and GitHub Actions for automated maintenance commits + +## Version 2026.4 + +See the [published v2026.4 release notes](https://github.com/WebGoat/WebGoat/releases/tag/v2026.4). + ## Version 2026.3 A large release: WebGoat moves to Spring Boot 4 and Java 25, WebWolf's mail functionality is folded into WebGoat, and diff --git a/pom.xml b/pom.xml index 92f9f8f6596..d399f04deb8 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.owasp.webgoat webgoat - 2026.5-SNAPSHOT + 2026.5 jar WebGoat