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);
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;";
+ }
+}
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")));
}