Skip to content

Commit d716cb3

Browse files
namedgraphclaude
andauthored
Fix clienturirewritefilter host (#277)
* Fix ClientUriRewriteFilter stripping subdomain from rewritten URI When the request URI was a subdomain (e.g. admin.atomgraph.com) and PROXY_HOST was set to the base domain (e.g. atomgraph.com), the filter replaced the entire host with proxyHost, losing the subdomain. The HTTP client then reused an existing SSL connection (SNI=atomgraph.com) for the subdomain request, causing nginx to return 421 Misdirected Request and breaking WebID agent loading in production. Fix preserves the subdomain prefix when building the rewritten URI. Adds unit tests covering exact host, subdomain, port, and query string cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Restrict subdomain preservation to same-domain proxy configs The previous fix unconditionally prepended the subdomain prefix to proxyHost, which broke dev setups using an internal proxy hostname (e.g. PROXY_HOST=nginx): admin.localhost was rewritten to admin.nginx instead of nginx, which doesn't resolve in Docker. Only preserve the subdomain when proxyHost equals host (the production case where both are the same domain). In that case the HTTP client would otherwise reuse an existing connection with SNI=host for the subdomain request, causing nginx to return 421 Misdirected Request. Update test to reflect the correct behaviour for the internal-proxy case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1527e50 commit d716cb3

File tree

2 files changed

+173
-1
lines changed

2 files changed

+173
-1
lines changed

src/main/java/com/atomgraph/linkeddatahub/client/filter/ClientUriRewriteFilter.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,18 @@ public void filter(ClientRequestContext cr) throws IOException
7070
String newScheme = cr.getUri().getScheme();
7171
if (getProxyScheme() != null) newScheme = getProxyScheme();
7272

73+
// Preserve subdomain prefix only when proxyHost is the same domain as host, to prevent
74+
// the HTTP client reusing a connection with a different TLS SNI (which causes 421).
75+
// When proxyHost is a distinct internal hostname (e.g. "nginx"), no collision is possible.
76+
String newHost = getProxyHost();
77+
if (cr.getUri().getHost().endsWith("." + getHost()) && getProxyHost().equals(getHost()))
78+
{
79+
String subdomainPrefix = cr.getUri().getHost().substring(0, cr.getUri().getHost().length() - getHost().length()); // e.g. "admin."
80+
newHost = subdomainPrefix + getProxyHost();
81+
}
82+
7383
// cannot use the URI class because query string with special chars such as '+' gets decoded
74-
URI newUri = UriBuilder.fromUri(cr.getUri()).scheme(newScheme).host(getProxyHost()).port(getProxyPort()).build();
84+
URI newUri = UriBuilder.fromUri(cr.getUri()).scheme(newScheme).host(newHost).port(getProxyPort()).build();
7585

7686
if (log.isDebugEnabled()) log.debug("Rewriting client request URI from '{}' to '{}'", cr.getUri(), newUri);
7787
cr.setUri(newUri);
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Copyright 2021 Martynas Jusevičius <martynas@atomgraph.com>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
package com.atomgraph.linkeddatahub.client.filter;
18+
19+
import jakarta.ws.rs.client.Client;
20+
import jakarta.ws.rs.client.ClientRequestContext;
21+
import jakarta.ws.rs.core.Configuration;
22+
import jakarta.ws.rs.core.Cookie;
23+
import jakarta.ws.rs.core.MediaType;
24+
import jakarta.ws.rs.core.MultivaluedHashMap;
25+
import jakarta.ws.rs.core.MultivaluedMap;
26+
import jakarta.ws.rs.core.Response;
27+
import java.io.IOException;
28+
import java.io.OutputStream;
29+
import java.lang.annotation.Annotation;
30+
import java.lang.reflect.Type;
31+
import java.net.URI;
32+
import java.util.Collection;
33+
import java.util.Date;
34+
import java.util.List;
35+
import java.util.Locale;
36+
import java.util.Map;
37+
import org.junit.Test;
38+
import static org.junit.Assert.*;
39+
40+
/**
41+
* Unit tests for {@link ClientUriRewriteFilter}.
42+
*
43+
* @author {@literal Martynas Jusevičius <martynas@atomgraph.com>}
44+
*/
45+
public class ClientUriRewriteFilterTest
46+
{
47+
48+
private static class StubRequestContext implements ClientRequestContext
49+
{
50+
private URI uri;
51+
private final MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
52+
53+
StubRequestContext(URI uri) { this.uri = uri; }
54+
55+
@Override public URI getUri() { return uri; }
56+
@Override public void setUri(URI uri) { this.uri = uri; }
57+
@Override public MultivaluedMap<String, Object> getHeaders() { return headers; }
58+
59+
@Override public Object getProperty(String name) { throw new UnsupportedOperationException(); }
60+
@Override public Collection<String> getPropertyNames() { throw new UnsupportedOperationException(); }
61+
@Override public void setProperty(String name, Object object) { throw new UnsupportedOperationException(); }
62+
@Override public void removeProperty(String name) { throw new UnsupportedOperationException(); }
63+
@Override public String getMethod() { throw new UnsupportedOperationException(); }
64+
@Override public void setMethod(String method) { throw new UnsupportedOperationException(); }
65+
@Override public MultivaluedMap<String, String> getStringHeaders() { throw new UnsupportedOperationException(); }
66+
@Override public String getHeaderString(String name) { throw new UnsupportedOperationException(); }
67+
@Override public Date getDate() { throw new UnsupportedOperationException(); }
68+
@Override public Locale getLanguage() { throw new UnsupportedOperationException(); }
69+
@Override public MediaType getMediaType() { throw new UnsupportedOperationException(); }
70+
@Override public List<MediaType> getAcceptableMediaTypes() { throw new UnsupportedOperationException(); }
71+
@Override public List<Locale> getAcceptableLanguages() { throw new UnsupportedOperationException(); }
72+
@Override public Map<String, Cookie> getCookies() { throw new UnsupportedOperationException(); }
73+
@Override public boolean hasEntity() { throw new UnsupportedOperationException(); }
74+
@Override public Object getEntity() { throw new UnsupportedOperationException(); }
75+
@Override public Class<?> getEntityClass() { throw new UnsupportedOperationException(); }
76+
@Override public Type getEntityType() { throw new UnsupportedOperationException(); }
77+
@Override public void setEntity(Object entity) { throw new UnsupportedOperationException(); }
78+
@Override public void setEntity(Object entity, Annotation[] annotations, MediaType mediaType) { throw new UnsupportedOperationException(); }
79+
@Override public Annotation[] getEntityAnnotations() { throw new UnsupportedOperationException(); }
80+
@Override public OutputStream getEntityStream() { throw new UnsupportedOperationException(); }
81+
@Override public void setEntityStream(OutputStream outputStream) { throw new UnsupportedOperationException(); }
82+
@Override public Client getClient() { throw new UnsupportedOperationException(); }
83+
@Override public Configuration getConfiguration() { throw new UnsupportedOperationException(); }
84+
@Override public void abortWith(Response response) { throw new UnsupportedOperationException(); }
85+
}
86+
87+
/** Non-matching host: filter must leave the URI untouched. */
88+
@Test
89+
public void testNoRewriteForNonMatchingHost() throws IOException
90+
{
91+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "http", "nginx", 9443);
92+
StubRequestContext ctx = new StubRequestContext(URI.create("https://other.org/path"));
93+
filter.filter(ctx);
94+
assertEquals(URI.create("https://other.org/path"), ctx.getUri());
95+
assertTrue(ctx.getHeaders().isEmpty());
96+
}
97+
98+
/** Exact host match: URI host is rewritten to proxyHost, scheme to proxyScheme. */
99+
@Test
100+
public void testRewriteExactHost() throws IOException
101+
{
102+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "http", "nginx", 9443);
103+
StubRequestContext ctx = new StubRequestContext(URI.create("https://example.com/path?q=1"));
104+
filter.filter(ctx);
105+
assertEquals(URI.create("http://nginx:9443/path?q=1"), ctx.getUri());
106+
assertEquals("example.com", ctx.getHeaders().getFirst("Host"));
107+
}
108+
109+
/** Exact host match with explicit port: Host header must include the original port. */
110+
@Test
111+
public void testRewriteExactHostWithPort() throws IOException
112+
{
113+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "http", "nginx", 9443);
114+
StubRequestContext ctx = new StubRequestContext(URI.create("https://example.com:4443/path"));
115+
filter.filter(ctx);
116+
assertEquals(URI.create("http://nginx:9443/path"), ctx.getUri());
117+
assertEquals("example.com:4443", ctx.getHeaders().getFirst("Host"));
118+
}
119+
120+
/**
121+
* Subdomain match with same-domain proxy host (production setup):
122+
* subdomain prefix must be preserved in the rewritten URI so the HTTP client
123+
* does not reuse a connection established with a different TLS SNI, which
124+
* would cause nginx to return 421 Misdirected Request.
125+
*/
126+
@Test
127+
public void testRewriteSubdomainPreservesSubdomainWithSameDomainProxy() throws IOException
128+
{
129+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "https", "example.com", 5443);
130+
StubRequestContext ctx = new StubRequestContext(URI.create("https://admin.example.com/acl/agents/123/"));
131+
filter.filter(ctx);
132+
assertEquals(URI.create("https://admin.example.com:5443/acl/agents/123/"), ctx.getUri());
133+
assertEquals("admin.example.com", ctx.getHeaders().getFirst("Host"));
134+
}
135+
136+
/**
137+
* Subdomain match with internal proxy host (Docker Compose setup):
138+
* subdomain prefix must NOT be prepended to the proxy hostname — the internal
139+
* hostname (e.g. "nginx") has no subdomain equivalent. nginx routes via Host header.
140+
*/
141+
@Test
142+
public void testRewriteSubdomainWithInternalProxyUsesProxyHostOnly() throws IOException
143+
{
144+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "http", "nginx", 9443);
145+
StubRequestContext ctx = new StubRequestContext(URI.create("https://admin.example.com/path"));
146+
filter.filter(ctx);
147+
assertEquals(URI.create("http://nginx:9443/path"), ctx.getUri());
148+
assertEquals("admin.example.com", ctx.getHeaders().getFirst("Host"));
149+
}
150+
151+
/** Query string with special characters must survive URI rewrite without decoding. */
152+
@Test
153+
public void testQueryStringNotDecoded() throws IOException
154+
{
155+
ClientUriRewriteFilter filter = new ClientUriRewriteFilter("example.com", "http", "nginx", 9443);
156+
StubRequestContext ctx = new StubRequestContext(URI.create("https://example.com/sparql?query=ASK+%7B%7D"));
157+
filter.filter(ctx);
158+
assertEquals("query=ASK+%7B%7D", ctx.getUri().getRawQuery());
159+
assertEquals("example.com", ctx.getHeaders().getFirst("Host"));
160+
}
161+
162+
}

0 commit comments

Comments
 (0)