diff --git a/zap/src/main/java/org/parosproxy/paros/common/ThreadPool.java b/zap/src/main/java/org/parosproxy/paros/common/ThreadPool.java index 532765ef6d2..3878b1ccd8b 100644 --- a/zap/src/main/java/org/parosproxy/paros/common/ThreadPool.java +++ b/zap/src/main/java/org/parosproxy/paros/common/ThreadPool.java @@ -24,10 +24,14 @@ // ZAP: 2019/06/05 Normalise format/style. package org.parosproxy.paros.common; +import java.util.Objects; +import java.util.stream.Stream; + public class ThreadPool { private Thread[] pool = null; private final String threadsBaseName; + private boolean interrupted; public ThreadPool(int maxThreadCount) { this(maxThreadCount, null); @@ -46,6 +50,9 @@ public ThreadPool(int maxThreadCount, String threadsBaseName) { * if none available. */ public synchronized Thread getFreeThreadAndRun(Runnable runnable) { + if (interrupted) { + return null; + } for (int i = 0; i < pool.length; i++) { if (pool[i] == null || !pool[i].isAlive()) { @@ -69,14 +76,19 @@ public synchronized Thread getFreeThreadAndRun(Runnable runnable) { * @param waitInMillis the number of milliseconds to wait for the threads */ public void waitAllThreadComplete(int waitInMillis) { + boolean waitInterrupted = false; for (int i = 0; i < pool.length; i++) { if (pool[i] != null && pool[i].isAlive()) { try { pool[i].join(waitInMillis); } catch (InterruptedException e) { + waitInterrupted = true; } } } + if (waitInterrupted) { + Thread.currentThread().interrupt(); + } } public boolean isAllThreadComplete() { @@ -87,4 +99,10 @@ public boolean isAllThreadComplete() { } return true; } + + public synchronized void interrupt() { + interrupted = true; + + Stream.of(pool).filter(Objects::nonNull).forEach(Thread::interrupt); + } } diff --git a/zap/src/main/java/org/parosproxy/paros/core/scanner/HostProcess.java b/zap/src/main/java/org/parosproxy/paros/core/scanner/HostProcess.java index 84b2ce19117..3d99979d0bd 100644 --- a/zap/src/main/java/org/parosproxy/paros/core/scanner/HostProcess.java +++ b/zap/src/main/java/org/parosproxy/paros/core/scanner/HostProcess.java @@ -347,6 +347,8 @@ public List getStartNodes() { public void stop() { isStop = true; getAnalyser().stop(); + + threadPool.interrupt(); } /** Main execution method */ @@ -715,6 +717,10 @@ private static void applyDeprecatedProperties(Plugin source, Plugin dest) { } private boolean obtainResponse(HistoryReference hRef, HttpMessage message) { + if (isStop()) { + return false; + } + try { getHttpSender().sendAndReceive(message); notifyNewMessage(message); diff --git a/zap/src/main/java/org/parosproxy/paros/core/scanner/Util.java b/zap/src/main/java/org/parosproxy/paros/core/scanner/Util.java index 5f22d3c71e3..d0825efe0b4 100644 --- a/zap/src/main/java/org/parosproxy/paros/core/scanner/Util.java +++ b/zap/src/main/java/org/parosproxy/paros/core/scanner/Util.java @@ -27,6 +27,7 @@ static void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } } diff --git a/zap/src/main/java/org/zaproxy/zap/users/User.java b/zap/src/main/java/org/zaproxy/zap/users/User.java index b7516e78a3a..99b43184ce7 100644 --- a/zap/src/main/java/org/zaproxy/zap/users/User.java +++ b/zap/src/main/java/org/zaproxy/zap/users/User.java @@ -163,10 +163,17 @@ public int getId() { * @param message the message */ public void processMessageToMatchUser(HttpMessage message) { + if (interrupted()) { + return; + } + // If the user is not yet authenticated, authenticate now // Make sure there are no simultaneous authentications for the same user synchronized (this) { if (this.requiresAuthentication()) { + if (interrupted()) { + return; + } this.authenticate(); if (this.requiresAuthentication()) { LOGGER.info("Authentication failed for user: {}", name); @@ -177,6 +184,14 @@ public void processMessageToMatchUser(HttpMessage message) { processMessageToMatchAuthenticatedSession(message); } + private boolean interrupted() { + if (Thread.currentThread().isInterrupted()) { + LOGGER.debug("Skipping {} authentication due to interruption.", this.name); + return true; + } + return false; + } + /** * Modifies a message so its Request Header/Body matches the web session corresponding to this * user. diff --git a/zap/src/test/java/org/parosproxy/paros/common/ThreadPoolUnitTest.java b/zap/src/test/java/org/parosproxy/paros/common/ThreadPoolUnitTest.java new file mode 100644 index 00000000000..352888cbf41 --- /dev/null +++ b/zap/src/test/java/org/parosproxy/paros/common/ThreadPoolUnitTest.java @@ -0,0 +1,94 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.parosproxy.paros.common; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class ThreadPoolUnitTest { + + @Test + void shouldInterruptRunningThreads() throws Exception { + // Given + ThreadPool pool = new ThreadPool(1); + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean wasInterrupted = new AtomicBoolean(); + Thread t = + pool.getFreeThreadAndRun( + () -> { + started.countDown(); + try { + Thread.sleep(Long.MAX_VALUE); + } catch (InterruptedException e) { + wasInterrupted.set(true); + } + }); + started.await(); + // When + pool.interrupt(); + t.join(2000); + // Then + assertThat(wasInterrupted.get(), is(true)); + } + + @Test + void shouldNotReturnThreadAfterInterrupt() { + // Given + ThreadPool pool = new ThreadPool(2); + pool.interrupt(); + // When + Thread t = pool.getFreeThreadAndRun(() -> {}); + // Then + assertThat(t, is(nullValue())); + } + + @Test + void shouldRestoreInterruptedStateAfterWaitAllThreadComplete() throws Exception { + // Given + ThreadPool pool = new ThreadPool(1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + pool.getFreeThreadAndRun( + () -> { + started.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + started.await(); + Thread.currentThread().interrupt(); + // When + try { + pool.waitAllThreadComplete(5000); + // Then + assertThat(Thread.currentThread().isInterrupted(), is(true)); + } finally { + Thread.interrupted(); + release.countDown(); + } + } +} diff --git a/zap/src/test/java/org/parosproxy/paros/core/scanner/UtilUnitTest.java b/zap/src/test/java/org/parosproxy/paros/core/scanner/UtilUnitTest.java index e65c045235c..0b3845c0787 100644 --- a/zap/src/test/java/org/parosproxy/paros/core/scanner/UtilUnitTest.java +++ b/zap/src/test/java/org/parosproxy/paros/core/scanner/UtilUnitTest.java @@ -27,6 +27,16 @@ class UtilUnitTest { + @Test + void shouldRestoreInterruptedStateWhenSleepIsInterrupted() { + // Given + Thread.currentThread().interrupt(); + // When + Util.sleep(10); + // Then + assertThat(Thread.interrupted(), is(true)); + } + @Test void shouldPauseForGivenDuration() { // Given diff --git a/zap/src/test/java/org/zaproxy/zap/users/UserUnitTest.java b/zap/src/test/java/org/zaproxy/zap/users/UserUnitTest.java index 1deab3374d2..fd446840b17 100644 --- a/zap/src/test/java/org/zaproxy/zap/users/UserUnitTest.java +++ b/zap/src/test/java/org/zaproxy/zap/users/UserUnitTest.java @@ -32,11 +32,13 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -222,6 +224,58 @@ void shouldNotAuthenticateIfNotRequired() { verify(user, never()).authenticate(); } + @Test + void shouldNotAuthenticateWhenInterruptedBeforeCall() { + // Given + User user = spy(new User(CONTEXT_ID, USER_NAME)); + Thread.currentThread().interrupt(); + // When + try { + user.processMessageToMatchUser(mock()); + } finally { + Thread.interrupted(); + } + // Then + verify(user, never()).authenticate(); + } + + @Test + void shouldNotAuthenticateWhenInterruptedWhileWaitingForLock() throws Exception { + // Given + User user = spy(new User(CONTEXT_ID, USER_NAME)); + doReturn(true).when(user).requiresAuthentication(); + + CountDownLatch lockAcquired = new CountDownLatch(1); + CountDownLatch releaseLock = new CountDownLatch(1); + + Thread lockHolder = + new Thread( + () -> { + synchronized (user) { + lockAcquired.countDown(); + try { + releaseLock.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + lockHolder.start(); + lockAcquired.await(); + + Thread caller = new Thread(() -> user.processMessageToMatchUser(mock())); + caller.start(); + while (caller.getState() != Thread.State.BLOCKED) { + Thread.sleep(50); + } + caller.interrupt(); + releaseLock.countDown(); + caller.join(); + + // Then + verify(user, never()).authenticate(); + } + @Test void shouldNotRequireAuthenticationAfterAuthentication() { // Given