Skip to content

jersey-mp-rest-client 3.1.11 nondeterministically copies inbound provider headers into outbound requests #6101

Description

@Verdent

Summary

Since jersey-mp-rest-client 3.1.11, headers returned by a registered InboundHeadersProvider can be copied automatically into an outbound request—even when no ClientHeadersFactory is registered.

The behavior is nondeterministic: different Rest Client instances in the same JVM can either propagate or omit the headers.

This may expose inbound Authorization, Cookie, forwarding, or gateway headers to a different destination.

Expected behavior

Inbound headers should only be available as the first argument to an explicitly configured ClientHeadersFactory. Without such a factory, they should not be propagated.

Headers added with RestClientBuilder.header(...) should remain separate from inbound headers.

Actual behavior

Jersey 3.1.11 stores the internal DefaultInboundHeaderProvider and application-provided InboundHeadersProvider instances in the same HashSet.

MethodModel.resolveCustomHeaders() accumulates provider results into one map. When DefaultInboundHeaderProvider is encountered, its update() method copies that entire accumulated map into outbound headers.

Therefore:

  • Application provider first: inbound headers are copied outbound.
  • Default provider first: only builder-provided headers are copied.

Reproducer

import java.net.URI;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.core.Response;

import org.eclipse.microprofile.rest.client.RestClientBuilder;
import org.glassfish.jersey.microprofile.restclient.InboundHeadersProvider;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class InboundHeadersProviderTest {
    private static final String HEADER = "X-Inbound-Only";
    private static final String RESULT = "X-Test-Leaked";

    @Test
    void inboundHeadersMustNotBeCopiedAutomatically() throws Exception {
        Set<Boolean> observed = new HashSet<>();

        for (int i = 0; i < 100; i++) {
            TestClient client = RestClientBuilder.newBuilder()
                    .baseUri(URI.create("http://example.invalid"))
                    .register(new TestInboundHeadersProvider())
                    .register(new InspectingFilter())
                    .build(TestClient.class);

            try {
                try (Response response = client.invoke()) {
                    observed.add(Boolean.parseBoolean(
                            response.getHeaderString(RESULT)));
                }
            } finally {
                ((AutoCloseable) client).close();
            }
        }

        assertEquals(Set.of(false), observed,
                     () -> "observed=" + observed);
    }

    @Path("/")
    interface TestClient {
        @GET
        Response invoke();
    }

    static final class TestInboundHeadersProvider
            implements InboundHeadersProvider {
        @Override
        public Map<String, List<String>> inboundHeaders() {
            return Map.of(HEADER, List.of("must-not-be-sent"));
        }

        @Override
        public int hashCode() {
            return 0;
        }
    }

    static final class InspectingFilter implements ClientRequestFilter {
        @Override
        public void filter(ClientRequestContext context) {
            boolean leaked = context.getHeaders().containsKey(HEADER);
            context.abortWith(Response.ok()
                                      .header(RESULT, leaked)
                                      .build());
        }
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions