Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion modules/qrest/src/main/java/org/jpos/qrest/RestSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
public class RestSession extends ChannelInboundHandlerAdapter {
private RestServer server;
private String contentKey;
private TrustedProxies trustedProxies;
private AttributeKey<HttpVersion> httpVersion = AttributeKey.valueOf("httpVersion");

static final AttributeKey<RestAccessState> ACCESS_STATE = AttributeKey.valueOf("qrestAccessState");
Expand All @@ -53,6 +54,8 @@ public class RestSession extends ChannelInboundHandlerAdapter {
RestSession(RestServer server) {
this.server = server;
contentKey = server.getConfiguration().get("content", null);
trustedProxies = TrustedProxies.parse(
server.getConfiguration().get("trusted-proxy-cidrs", null));
}

@Override
Expand Down Expand Up @@ -190,7 +193,15 @@ private void captureRequest(ChannelHandlerContext ch, FullHttpRequest request) {
state.startNanos = System.nanoTime();
state.method = request.method().name();
state.path = stripQuery(request.uri());
state.remote = remoteAddress(ch);
// Behind a proxy the socket peer is the proxy, not the caller. When
// the peer is inside trusted-proxy-cidrs, record the client the
// outermost trusted proxy witnessed (rightmost untrusted entry of
// X-Forwarded-For); from any other peer the header is untrusted
// client input and the socket address is the only honest answer.
String peer = remoteAddress(ch);
state.remote = trustedProxies != null
? trustedProxies.resolveClient(peer, request.headers().getAll("X-Forwarded-For"))
: peer;
state.requestBytes = (long) request.content().readableBytes();
state.scheme = server.isTLSEnabled() ? "https" : "http";
state.protocolVersion = stripProtocol(request.protocolVersion().text());
Expand Down
174 changes: 174 additions & 0 deletions modules/qrest/src/main/java/org/jpos/qrest/TrustedProxies.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2026 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.qrest;

import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;

/**
* Resolves the originating client address of a proxied HTTP request from
* {@code X-Forwarded-For}, but only when the socket peer is a proxy the
* operator explicitly trusts.
*
* <p>Configured on the {@code qrest} QBean via {@code trusted-proxy-cidrs},
* a comma-separated list of CIDRs (e.g. {@code 10.42.0.0/16, 127.0.0.1/32}).
* When the property is absent the feature is off and the access log keeps
* recording the socket peer — forwarded headers are client-controlled input
* and must never be believed coming from an untrusted peer, or any caller
* could forge its logged identity with a single crafted header.</p>
*
* <p>Resolution walks the forwarded chain right to left — the rightmost
* entries were appended by the proxies closest to this server — skipping
* addresses inside the trusted CIDRs; the first address NOT in the trusted
* set is the client as witnessed by the outermost trusted proxy. A chain
* consisting solely of trusted addresses yields its leftmost entry (the
* originator lives inside the trusted network); an empty or absent header
* yields the socket peer.</p>
*/
public class TrustedProxies {
private final List<Cidr> cidrs;

private TrustedProxies(List<Cidr> cidrs) {
this.cidrs = cidrs;
}

/**
* Parses a comma-separated CIDR list.
*
* @param csv the {@code trusted-proxy-cidrs} property value, may be null
* @return a resolver, or {@code null} when the value is null/blank
* @throws IllegalArgumentException on a malformed CIDR — a typo must
* fail deployment loudly rather than silently disable the
* feature or, worse, trust the wrong network
*/
public static TrustedProxies parse(String csv) {
if (csv == null || csv.isBlank())
return null;
List<Cidr> cidrs = new ArrayList<>();
for (String part : csv.split(",")) {
String v = part.trim();
if (!v.isEmpty())
cidrs.add(Cidr.parse(v));
}
return cidrs.isEmpty() ? null : new TrustedProxies(cidrs);
}

public boolean isTrusted(String ip) {
InetAddress addr = parseAddress(ip);
if (addr == null)
return false;
for (Cidr c : cidrs)
if (c.matches(addr))
return true;
return false;
}

/**
* Returns the client address to record for a request that arrived from
* {@code socketPeer} carrying the given {@code X-Forwarded-For} header
* values (one list element per header occurrence).
*/
public String resolveClient(String socketPeer, List<String> forwardedFor) {
if (!isTrusted(socketPeer))
return socketPeer;
List<String> chain = new ArrayList<>();
if (forwardedFor != null) {
for (String header : forwardedFor) {
if (header == null)
continue;
for (String part : header.split(",")) {
String ip = normalize(part);
if (ip != null)
chain.add(ip);
}
}
}
for (int i = chain.size() - 1; i >= 0; i--) {
if (!isTrusted(chain.get(i)))
return chain.get(i);
}
return chain.isEmpty() ? socketPeer : chain.get(0);
}

/**
* Strips surrounding quotes, IPv6 brackets and an IPv4 port suffix,
* returning null unless the remainder parses as an address literal.
*/
private static String normalize(String value) {
if (value == null)
return null;
String v = value.trim();
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\""))
v = v.substring(1, v.length() - 1).trim();
if (v.isEmpty() || "unknown".equalsIgnoreCase(v))
return null;
if (v.startsWith("[") && v.contains("]")) {
v = v.substring(1, v.indexOf(']'));
} else {
int colon = v.indexOf(':');
if (colon > 0 && v.indexOf(':', colon + 1) < 0)
v = v.substring(0, colon); // IPv4:port
}
return parseAddress(v) != null ? v : null;
}

private static InetAddress parseAddress(String ip) {
if (ip == null)
return null;
try {
// Literals only — InetAddress.getByName on a hostname would do DNS.
if (!ip.matches("\\d{1,3}(\\.\\d{1,3}){3}") && !ip.contains(":"))
return null;
return InetAddress.getByName(ip);
} catch (Exception e) {
return null;
}
}

private record Cidr(InetAddress network, int prefixLength) {
static Cidr parse(String value) {
String[] parts = value.split("/", 2);
InetAddress network = parseAddress(parts[0].trim());
if (network == null)
throw new IllegalArgumentException("invalid CIDR address: " + value);
int bits = network.getAddress().length * 8;
int prefix = parts.length == 2 ? Integer.parseInt(parts[1].trim()) : bits;
if (prefix < 0 || prefix > bits)
throw new IllegalArgumentException("invalid CIDR prefix: " + value);
return new Cidr(network, prefix);
}

boolean matches(InetAddress address) {
byte[] a = address.getAddress();
byte[] n = network.getAddress();
if (a.length != n.length)
return false;
int fullBytes = prefixLength / 8;
int rest = prefixLength % 8;
for (int i = 0; i < fullBytes; i++)
if (a[i] != n[i])
return false;
if (rest == 0)
return true;
int mask = 0xff << (8 - rest);
return (a[fullBytes] & mask) == (n[fullBytes] & mask);
}
}
}
29 changes: 29 additions & 0 deletions modules/qrest/src/test/java/org/jpos/qrest/RestSessionTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ void flushesResidualRequestAtCloseWhenSendResponseNeverRan() {
assertNoLegacyAcceptOrCloseLogs();
}

@Test
void forwardedHeaderFromUntrustedPeerIsIgnored() throws Exception {
// trusted-proxy-cidrs is configured, but the EmbeddedChannel peer
// ("embedded", not an address literal) is not inside it — a forged
// X-Forwarded-For from such a peer must never become the logged
// remote, or any direct caller could pick its own audit identity.
CapturingRestServer proxied = new CapturingRestServer();
proxied.setName("rest-proxied");
proxied.setConfiguration(new SimpleConfiguration(new java.util.Properties() {{
put("trusted-proxy-cidrs", "10.42.0.0/16");
}}));
CapturingRestSession psession = new CapturingRestSession(proxied);
EmbeddedChannel pchannel = new EmbeddedChannel(psession);

DefaultFullHttpRequest request = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/balance");
request.headers().set("X-Forwarded-For", "203.0.113.7");
pchannel.writeInbound(request);
Context ctx = proxied.lastQueuedContext;
ctx.put(Constants.RESPONSE, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK));
new SendResponse().commit(1L, ctx);
while (pchannel.readOutbound() != null) { }

assertEquals(1, psession.emitted.size());
assertNotEquals("203.0.113.7", psession.emitted.get(0).remote(),
"X-Forwarded-For from an untrusted socket peer must not be believed");
pchannel.finishAndReleaseAll();
}

@Test
void emitsNothingIfChannelClosesWithoutAnyRequest() {
channel.close().syncUninterruptibly();
Expand Down
113 changes: 113 additions & 0 deletions modules/qrest/src/test/java/org/jpos/qrest/TrustedProxiesTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2026 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.qrest;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class TrustedProxiesTest {
private static final TrustedProxies POD_NET = TrustedProxies.parse("10.42.0.0/16");

@Test
void blankConfigDisablesTheFeature() {
assertNull(TrustedProxies.parse(null));
assertNull(TrustedProxies.parse(""));
assertNull(TrustedProxies.parse(" , "));
}

@Test
void malformedCidrFailsLoudly() {
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.parse("not-a-cidr"));
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.parse("10.42.0.0/99"));
}

@Test
void cidrMembership() {
assertTrue(POD_NET.isTrusted("10.42.7.13"));
assertFalse(POD_NET.isTrusted("10.43.0.1"));
assertFalse(POD_NET.isTrusted("203.0.113.7"));
assertFalse(POD_NET.isTrusted("embedded")); // non-literal peer
assertFalse(POD_NET.isTrusted(null));
}

@Test
void untrustedPeerNeverGetsItsHeaderBelieved() {
// Direct caller forging X-Forwarded-For: the socket wins.
assertEquals("203.0.113.7",
POD_NET.resolveClient("203.0.113.7", List.of("198.51.100.99")));
}

@Test
void trustedPeerYieldsRightmostUntrustedEntry() {
assertEquals("203.0.113.7",
POD_NET.resolveClient("10.42.0.9", List.of("203.0.113.7")));
// Client-forged prefix entries are ignored; the entry appended by
// the trusted proxy (rightmost untrusted) wins.
assertEquals("203.0.113.7",
POD_NET.resolveClient("10.42.0.9", List.of("1.2.3.4, 203.0.113.7")));
// Multiple header occurrences concatenate in order.
assertEquals("203.0.113.7",
POD_NET.resolveClient("10.42.0.9", List.of("1.2.3.4", "203.0.113.7")));
}

@Test
void chainOfTrustedProxiesSkipsToTheClient() {
TrustedProxies tp = TrustedProxies.parse("10.42.0.0/16, 192.168.1.1/32");
assertEquals("203.0.113.7",
tp.resolveClient("10.42.0.9", List.of("203.0.113.7, 192.168.1.1")));
}

@Test
void allTrustedChainYieldsOriginator() {
assertEquals("10.42.3.3",
POD_NET.resolveClient("10.42.0.9", List.of("10.42.3.3, 10.42.0.5")));
}

@Test
void missingOrGarbageHeaderFallsBackToSocketPeer() {
assertEquals("10.42.0.9", POD_NET.resolveClient("10.42.0.9", List.of()));
assertEquals("10.42.0.9", POD_NET.resolveClient("10.42.0.9", null));
assertEquals("10.42.0.9",
POD_NET.resolveClient("10.42.0.9", List.of("unknown, not-an-ip")));
}

@Test
void normalizationHandlesPortsBracketsAndQuotes() {
assertEquals("203.0.113.7",
POD_NET.resolveClient("10.42.0.9", List.of("203.0.113.7:4711")));
assertEquals("2001:db8::1",
POD_NET.resolveClient("10.42.0.9", List.of("[2001:db8::1]:443")));
assertEquals("203.0.113.7",
POD_NET.resolveClient("10.42.0.9", List.of("\"203.0.113.7\"")));
}

@Test
void ipv6Cidr() {
TrustedProxies tp = TrustedProxies.parse("2001:db8::/32");
assertTrue(tp.isTrusted("2001:db8::1"));
assertFalse(tp.isTrusted("2001:db9::1"));
}
}
Loading