diff --git a/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java b/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java index 65ec8ff1d..16851b735 100644 --- a/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java +++ b/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java @@ -43,6 +43,7 @@ public class RestSession extends ChannelInboundHandlerAdapter { private RestServer server; private String contentKey; + private TrustedProxies trustedProxies; private AttributeKey httpVersion = AttributeKey.valueOf("httpVersion"); static final AttributeKey ACCESS_STATE = AttributeKey.valueOf("qrestAccessState"); @@ -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 @@ -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()); diff --git a/modules/qrest/src/main/java/org/jpos/qrest/TrustedProxies.java b/modules/qrest/src/main/java/org/jpos/qrest/TrustedProxies.java new file mode 100644 index 000000000..830f3bcc7 --- /dev/null +++ b/modules/qrest/src/main/java/org/jpos/qrest/TrustedProxies.java @@ -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 . + */ + +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. + * + *

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.

+ * + *

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.

+ */ +public class TrustedProxies { + private final List cidrs; + + private TrustedProxies(List 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 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 forwardedFor) { + if (!isTrusted(socketPeer)) + return socketPeer; + List 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); + } + } +} diff --git a/modules/qrest/src/test/java/org/jpos/qrest/RestSessionTest.java b/modules/qrest/src/test/java/org/jpos/qrest/RestSessionTest.java index f5da674e0..ea1187742 100644 --- a/modules/qrest/src/test/java/org/jpos/qrest/RestSessionTest.java +++ b/modules/qrest/src/test/java/org/jpos/qrest/RestSessionTest.java @@ -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(); diff --git a/modules/qrest/src/test/java/org/jpos/qrest/TrustedProxiesTest.java b/modules/qrest/src/test/java/org/jpos/qrest/TrustedProxiesTest.java new file mode 100644 index 000000000..a1e465125 --- /dev/null +++ b/modules/qrest/src/test/java/org/jpos/qrest/TrustedProxiesTest.java @@ -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 . + */ + +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")); + } +}