BufferTortureTestBase.java

/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2026 Red Hat, Inc., and individual contributors
 * as indicated by the @author tags.
 *
 * 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 io.undertow.websockets.core;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import org.junit.AfterClass;
import org.junit.Before;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.MethodSorters;

import io.undertow.testutils.AjpIgnore;
import io.undertow.testutils.DefaultServer;
import io.undertow.testutils.HttpOneOnly;
import io.undertow.testutils.ProxyIgnore;
import io.undertow.websockets.utils.WebSocketPayloadUtil;
import io.undertow.websockets.utils.WebSocketRawTestClient;
import io.undertow.websockets.utils.WebSocketRawTestClient.RawFrame;
import io.undertow.websockets.utils.WebSocketRawTestClient.UpgradeResult;

/**
 * Base class for buffer/ping tests.
 * @author Avishek Sarkar
 * @author baranowb
 */
@RunWith(DefaultServer.class)
@AjpIgnore
@HttpOneOnly
@ProxyIgnore
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class BufferTortureTestBase {
    protected static final String VALID_ORIGIN = "https://trusted.example.com";
    protected static final String INVALID_ORIGIN = "https://evil.example.com";
    protected static final AtomicInteger upgradesAccepted = new AtomicInteger(0);
    protected static final AtomicInteger upgradesRejected = new AtomicInteger(0);
    protected static final AtomicInteger messagesReceived = new AtomicInteger(0);
    protected static final AtomicBoolean originCheckCalled = new AtomicBoolean(false);
    protected static final AtomicBoolean deflateAvailable = new AtomicBoolean(false);

    protected static volatile CountDownLatch messageLatch = new CountDownLatch(1);
    protected static final Set<String> allowedOrigins = Set.of(VALID_ORIGIN);
    protected static volatile String lastMessage = null;

    protected static final Charset US_ASCII = StandardCharsets.US_ASCII;

    //@BeforeClass //https://redhat.atlassian.net/browse/UNDERTOW-2769
    protected static final AtomicBoolean INIT = new AtomicBoolean(false);

    public static void setupTorture() {
        if (INIT.get()) {
            return;
        }
        INIT.set(true);
        try (WebSocketRawTestClient probe = createRawClient()) {
            UpgradeResult result = probe.upgradeWithDeflate("/ws", VALID_ORIGIN);
            assertTrue(result.toString(), result.isUpgraded());
            assertTrue(result.toString(), WebSocketRawTestClient.isDeflateNegotiated(result));
            deflateAvailable.set(result.isUpgraded() && WebSocketRawTestClient.isDeflateNegotiated(result));
        } catch (Exception e) {
            deflateAvailable.set(false);
            throw new RuntimeException(e);
        }
    }

    @AfterClass
    public static void exit() {
        // https://redhat.atlassian.net/browse/UNDERTOW-2769
        INIT.set(false);
    }

    @Before
    public void clearSlate() {
        setupTorture();
        upgradesAccepted.set(0);
        upgradesRejected.set(0);
        messagesReceived.set(0);
        originCheckCalled.set(false);
        lastMessage = null;
        messageLatch = new CountDownLatch(1);
    }

    protected static WebSocketRawTestClient createRawClient() throws IOException {
        return new WebSocketRawTestClient(DefaultServer.getHostAddress(), DefaultServer.getHostPort());
    }

    @Test
    public void test_1_baseline_Origin() throws Exception {
        System.out.println();
        System.out.println("������ Baseline 1a: Valid Origin ������");

        try (WebSocketRawTestClient client = createRawClient()) {
            UpgradeResult result = client.upgrade("/ws", VALID_ORIGIN);

            System.out.printf("  HTTP status: %d%n", result.getStatusCode());
            assertTrue("Valid Origin must be accepted ��� got " + result.getStatusCode(), result.isUpgraded());

            client.sendText("UNDERTOW_BASELINE");
            boolean received = messageLatch.await(3, TimeUnit.SECONDS);

            System.out.printf("  Message delivered: %s%n", received);
            assertTrue("Message must be delivered", received);
            assertEquals("UNDERTOW_BASELINE", BufferTortureTestBase.lastMessage);
        }

        System.out.println("  PASS ��� Valid Origin accepted, message delivered");
    }

    @Test
    public void test_2_baseline_InValidOrigin() throws Exception {
        System.out.println();
        System.out.println("������ Baseline 1b: Invalid Origin ������");

        try (WebSocketRawTestClient client = createRawClient()) {
            UpgradeResult result = client.upgrade("/ws", INVALID_ORIGIN);

            System.out.printf("  HTTP status: %d%n", result.getStatusCode());
            assertFalse("Invalid Origin must NOT be upgraded", result.isUpgraded());
            assertTrue("Origin check must have been called", originCheckCalled.get());
        }

        System.out.println("  PASS ��� Invalid Origin correctly rejected");
        System.out.println("  Baseline: Origin check is functional and enforced");
    }

    @Test
    public void test_3_deflateFragment() throws Exception {
        assertTrue("permessage-deflate not negotiated on Undertow", deflateAvailable.get());

        System.out.println();
        System.out.println("������ V1: Deflate+Fragment (Undertow) ������");

        try (WebSocketRawTestClient client = createRawClient()) {
            UpgradeResult result = client.upgradeWithDeflate("/ws", VALID_ORIGIN);
            assertTrue("Upgrade must succeed", result.isUpgraded());

            // buildDeflateFragmentBomb(fragmentCount, totalDecompressedSize):
            // Second param is TOTAL decompressed size (not per-fragment).
            // Compresses 10MB of 'A's into ~16KB wire, splits across 1000 frames.
            byte[] bomb = WebSocketPayloadUtil.buildDeflateFragmentBomb(1_000, 15 * 1024 * 1024);

            boolean accepted = true;
            long start = System.currentTimeMillis();
            try {
                client.sendRaw(bomb);
            } catch (IOException e) {
                e.printStackTrace();
                accepted = false;
            }
            long elapsed = System.currentTimeMillis() - start;

            Thread.sleep(2000);

            int msgs = messagesReceived.get();
            RawFrame x = client.readAvailableFrame(500);
            assertNotNull(x);
            assertTrue(x.toString(), x.isClose());
            System.out.printf("  Wire size    : %,d bytes (%,d KB)%n", bomb.length, bomb.length / 1024);
            System.out.printf("  Amplification: %,dx%n", (10L * 1024 * 1024) / Math.max(1, bomb.length));
            System.out.printf("  Accepted     : %s%n", accepted);
            System.out.printf("  Send time    : %,dms%n", elapsed);
            System.out.printf("  Messages     : %d%n", msgs);
            System.out.printf("  Close     : %d '%d' '%s' %n", x.getOpCode(), x.getStatusCode(), x.getRawContent());

            assertEquals("No message delivered ��� FIN never sent", 0, msgs);
            assertTrue(
                    "Undertow must accept deflate+fragment bomb ��� " +
                    "PerMessageDeflateFunction has no buffer size limit", accepted);

            System.out.println("  FINDING: Undertow accepted deflate+fragment bomb");
            System.out.println("  PerMessageDeflateFunction.largerBuffer() doubles buffer");
            System.out.println("  without upper limit: 8KB ��� 16KB ��� 32KB ��� ... ��� unbounded - FALSE, close message received");
        }
    }

    @Test
    public void test_4_compressionBomb() throws Exception {
        assertTrue("permessage-deflate not negotiated on Undertow", deflateAvailable.get());

        System.out.println();
        System.out.println("������ V2: Compression Bomb (Undertow, doubling growth) ������");

        byte[] uncompressed = new byte[15 * 1024 * 1024];
        java.util.Arrays.fill(uncompressed, (byte) 'A');

        try (WebSocketRawTestClient client = createRawClient()) {
            UpgradeResult result = client.upgradeWithDeflate("/ws", VALID_ORIGIN);
            assertTrue("Upgrade must succeed", result.isUpgraded());

            byte[] frame = WebSocketPayloadUtil.buildCompressedFrame(
                    WebSocketPayloadUtil.OP_TEXT, true, uncompressed);

            boolean accepted = true;
            try {
                client.sendRaw(frame);
            } catch (IOException e) {
                accepted = false;
            }

            // Expected: Undertow decompresses via doubling buffers, then delivers.
            // No maxAllocation equivalent ��� buffer grows until decompression completes.
            boolean delivered = messageLatch.await(10, TimeUnit.SECONDS);
            RawFrame x = client.readAvailableFrame(500);
            assertNotNull(x);
            assertTrue(x.toString(), x.isClose());
            System.out.printf("  Wire size    : %,d bytes (%,d KB)%n", frame.length, frame.length / 1024);
            System.out.printf("  Amplification: %,dx%n", (10L * 1024 * 1024) / Math.max(1, frame.length));
            System.out.printf("  Accepted     : %s%n", accepted);
            System.out.printf("  Delivered    : %s%n", delivered);
            System.out.printf("  Close     : %d '%d' '%s' %n", x.getOpCode(), x.getStatusCode(), x.getRawContent());

            if (delivered) {
                System.out.println("  FINDING: Undertow decompressed AND DELIVERED 10MB bomb");
                System.out.printf("  Message length: %,d bytes%n",
                        lastMessage != null ? lastMessage.length() : 0);
                System.out.println("  Buffer growth: 8KB ��� 16KB ��� ... ��� 16MB (doubling, no limit)");
                System.out.println("  No equivalent to maxTextMessageSize or maxAllocation.");
                fail();
            } else if (accepted) {
                System.out.println("  FINDING: Decompression occurred (doubling buffers) but not delivered");
                assertTrue("Undertow must attempt decompression ��� no buffer size limit exists", accepted || delivered);
            }
        }
    }

    // ������ V3: Ping Flood ������������������������������������������������������������������������������������������������������������������������������������������������������

    @Test
    public void test_5_pingFlood() throws Exception {
        System.out.println();
        System.out.println("������ V3: Ping Flood (Undertow) ������");

        try (WebSocketRawTestClient client = createRawClient()) {
            UpgradeResult result = client.upgrade("/ws", VALID_ORIGIN);
            assertTrue("Upgrade must succeed", result.isUpgraded());

            long start = System.currentTimeMillis();

            client.sendRaw(WebSocketPayloadUtil.buildPingFlood(10_000));
            long sendElapsed = System.currentTimeMillis() - start;

            int pongs = client.countPongFrames(5000);

            System.out.printf("  Pings sent        : 10,000%n");
            System.out.printf("  Pongs received    : %,d%n", pongs);
            System.out.printf("  Send time         : %,dms%n", sendElapsed);
            System.out.printf("  App handler calls : %d%n", messagesReceived.get());

            assertTrue("Undertow responds to Pings at the protocol layer ��� " +
                    "AbstractReceiveListener.onFullTextMessage never consulted", pongs > 0);
            assertEquals("No app handler calls ��� Pong generated by WebSocketChannel", 0, messagesReceived.get());

            if (pongs > 0) {
                System.out.println("  FINDING: Undertow processed Pings at protocol layer");
                System.out.printf("  %d%% pong rate, app handler: 0 calls%n",
                        (pongs * 100) / 10_000);
                assertTrue("Pong count low enough", (pongs < AbstractReceiveListener.DEFAULT_WEB_SOCKETS_PING_MAX_PER_WINDOW+30));
            } else {
                fail();
            }
        }
    }

    // ������ V4: Doubling Growth Pattern (Undertow-specific) ���������������������������������������������������
    @Test
    public void test_6_doublingGrowthPattern() throws Exception {
        assertTrue("permessage-deflate not negotiated on Undertow", deflateAvailable.get());

        System.out.println();
        System.out.println("������ Undertow-Specific: Doubling Buffer Growth Pattern ������");
        System.out.println("  PerMessageDeflateFunction.largerBuffer() doubles on each call:");
        System.out.println("  8KB ��� 16KB ��� 32KB ��� 64KB ��� 128KB ��� 256KB ��� 512KB ��� 1MB ��� ...");
        System.out.println("  No upper limit. Testing escalating decompressed sizes:");
        System.out.println();

        // Send escalating single-frame bombs to observe the doubling pattern
        int[] sizes = {64 * 1024, 256 * 1024, 1024 * 1024, 4 * 1024 * 1024};
        String[] labels = {"64KB", "256KB", "1MB", "4MB"};
        int deliveredCount = 0;
        boolean smallestDelivered = false;

        for (int i = 0; i < sizes.length; i++) {
            clearSlate();

            byte[] uncompressed = new byte[sizes[i]];
            java.util.Arrays.fill(uncompressed, (byte) 'A');

            try (WebSocketRawTestClient client = createRawClient()) {
                UpgradeResult result = client.upgradeWithDeflate("/ws", VALID_ORIGIN);
                if (!result.isUpgraded()) {
                    System.out.printf("  %s: Upgrade failed%n", labels[i]);
                    continue;
                }

                byte[] frame = WebSocketPayloadUtil.buildCompressedFrame(
                        WebSocketPayloadUtil.OP_TEXT, true, uncompressed);

                boolean accepted = true;
                try {
                    client.sendRaw(frame);
                } catch (IOException e) {
                    accepted = false;
                }

                boolean delivered = messageLatch.await(5, TimeUnit.SECONDS);

                if (delivered) {
                    deliveredCount++;
                    if (i == 0) smallestDelivered = true;
                }

                // Approximate doublings from initial pool buffer (assumed 8KB)
                int doublings = 0;
                int bufSize = 8192;
                while (bufSize < sizes[i]) {
                    bufSize *= 2;
                    doublings++;
                }

                System.out.printf("  %s: wire=%,d bytes, delivered=%s, ~%d doublings%n",
                        labels[i], frame.length, delivered, doublings);
            }
        }

        if (deliveredCount == sizes.length) {
            System.out.println("  FINDING: All sizes accepted and delivered. Buffer doubles");
            System.out.println("  without limit until decompression completes. No maximum");
            System.out.println("  buffer size parameter exists in PerMessageDeflateHandshake.");
            fail();
        } else {
            System.out.printf("  Partial: %d/%d sizes delivered ��� threshold found.%n",
                    deliveredCount, sizes.length);
            assertTrue(
                    "64KB decompressed bomb must be delivered ��� confirms " +
                    "PerMessageDeflateFunction doubling growth with no upper limit", smallestDelivered);
        }

    }
}