DEV Community

Solon Framework
Solon Framework

Posted on

Solon Cloud Gateway 4.0.5: Tune Every Knob (Predicates, Filters, X-Forwarded, HttpClient) — and Stop Trusting Spoofable IPs

A gateway sits in front of every request, so the knobs you can't reach are the ones you end up fighting at 2am. Solon Cloud Gateway 4.0.5 (tagged 2026-08-10) opens up almost every one of them: predicates and filters can be disabled per-factory, X-Forwarded-* outbound header generation is fully controllable, the HttpClient gets pool/websocket/proxy/SSL sections, and — the one I'd call a quiet security fix — the RemoteAddr predicate no longer trusts X-Forwarded-For. Here's what changed and how to use it.

The nine changes at a glance

What Config prefix Kind
Predicate factory on/off solon.cloud.gateway.predicate.* bool
Filter factory on/off solon.cloud.gateway.filter.* bool
X-Forwarded-* header generation solon.cloud.gateway.xForwarded.* map
HttpClient connection pool solon.cloud.gateway.httpClient.pool.* map
HttpClient WebSocket behavior solon.cloud.gateway.httpClient.websocket.* map
HttpClient outbound proxy solon.cloud.gateway.httpClient.proxy.* map
HttpClient mTLS / trust store solon.cloud.gateway.httpClient.ssl.* map
New XForwardedRemoteAddr predicate n/a (route config)
RemoteAddr predicate semantics n/a (route config)

All of them live in org.noear.solon.cloud.gateway and bind through GatewayProperties under the solon.cloud.gateway root.

Toggle predicates and filters like feature flags

Before 4.0.5 every registered factory was always active. Now GatewayProperties carries two boolean maps — predicate and filter — bound to solon.cloud.gateway.predicate.* and solon.cloud.gateway.filter.*:

solon:
  cloud:
    gateway:
      predicate:
        remote-addr: false   # disable the RemoteAddr predicate
      filter:
        strip-prefix: false  # disable the StripPrefix filter
Enter fullscreen mode Exit fullscreen mode

The map key is the factory prefix, but you don't have to match casing: RouteFactoryManager normalizes keys (lowercase, hyphens/underscores stripped), so remote-addr, remote_addr, and RemoteAddr all resolve to the same factory. A disabled factory is treated as unregistered — getPredicate() / getFilter() return null, the route simply skips that step, and nothing throws.

This matters more than it sounds. If you don't use cookies or query predicates in front of a public endpoint, switching them off is a small but real reduction of the attack surface and removes dead code paths from the routing hot path. The gateway registers ten predicate factories and eight filter factories by default:

Predicates: After, Before, Cookie, Header, Host, Method, Path, Query, RemoteAddr, XForwardedRemoteAddr
Filters:    AddRequestHeader, AddResponseHeader, PrefixPath, RedirectTo, RewritePath,
            RemoveRequestHeader, RemoveResponseHeader, StripPrefix
Enter fullscreen mode Exit fullscreen mode

Control the X-Forwarded-* headers you emit

XForwardedProperties (new in 4.0.5) governs which X-Forwarded-* headers the gateway writes on outbound requests. It has a master switch plus per-header enabled/append pairs:

solon:
  cloud:
    gateway:
      xForwarded:
        enabled: true        # master switch; false = emit no X-Forwarded-* at all
        forEnabled: true     # X-Forwarded-For
        forAppend: true      # true = append the peer socket IP hop by hop; false = overwrite
        hostEnabled: true    # X-Forwarded-Host (from the original Host header)
        hostAppend: false    # overwrite mode
        portEnabled: true    # X-Forwarded-Port (defaults to 80/443 for http/https)
        portAppend: false
        protoEnabled: true   # X-Forwarded-Proto (http/https)
        protoAppend: false
Enter fullscreen mode Exit fullscreen mode

The defaults follow one simple rule: for appends, host/port/proto overwrite. X-Forwarded-For accumulates each hop's socket IP so downstream services can see the real chain; the other three are treated as single-value facts about the original request. If you run multiple layers of proxies, you'll likely want forAppend: false at the outermost edge so the client-provided value is replaced rather than extended.

HttpClient: every dial turned into configuration

The HttpClientProperties (extends TimeoutProperties) now exposes five areas — and 4.0.5 also tightens the default response timeout from 1800s down to 60s (0 disables it):

solon:
  cloud:
    gateway:
      httpClient:
        compression: true          # outbound GZip compression (default false)
        connectTimeout: 10         # seconds
        requestTimeout: 10         # seconds, waiting for upstream response headers
        responseTimeout: 60        # seconds, overall fallback; 0 = disabled
        pool:
          maxConnections: 250      # per upstream host, ≈ QPS × avg response seconds
          maxWaitQueueSize: 1000   # queue limit; overflow → fast-fail 503; -1 = unlimited
          maxIdleTime: 60          # idle seconds, matches common LB 60s idle drop
          keepAliveTimeout: 60
          maxPools: 256            # pool count; excess → LRU eviction to prevent leaks
        websocket:
          maxConnections: 200
          idleTimeout: 60
          closingTimeout: 10
          pingInterval: 30         # heartbeat seconds; ≤ 0 disables
        proxy:
          enabled: false
          host: 127.0.0.1
          port: 8080
          type: HTTP               # HTTP | SOCKS4 | SOCKS5
          username: ""
          password: ""
          nonProxyHostsPattern: "" # regex of hosts that bypass the proxy
        ssl:
          enabled: false
          keyStore: ""             # client cert keystore path
          keyStorePassword: ""
          keyStoreType: JKS        # JKS | PKCS12
          keyPassword: ""          # falls back to keyStorePassword
          trustStore: ""           # custom trust store
          trustStorePassword: ""
          trustStoreType: JKS
Enter fullscreen mode Exit fullscreen mode

A few defaults are worth internalizing: the pool is per-upstream-host and sized by maxConnections: 250; when the pool is full and the wait queue exceeds maxWaitQueueSize: 1000, requests fast-fail with 503 instead of hanging forever; maxPools: 256 bounds the number of host pools and LRU-evicts the idle ones so a drifting set of upstream instances can't leak pools. The SSL section turns the gateway's outbound HTTP client into an mTLS client — useful when the upstream side expects client certificates.

The one that matters: RemoteAddr stopped trusting X-Forwarded-For

This is the change I'd flag in any upgrade review. Up to v4.0.0, RemoteAddrPredicateFactory.test() matched against ctx.realIp():

// v4.0.0 and earlier — trusts client-supplied headers
public boolean test(ExContext ctx) {
    String ip = ctx.realIp();
    ...
}
Enter fullscreen mode Exit fullscreen mode

And ExContextImpl.realIp() resolves in this order: X-Real-IP header → X-Forwarded-For header (first value before the comma) → socket peer address. All three of the header sources are client-controlled. Anyone who can set headers can spoof them:

GET /admin HTTP/1.1
X-Forwarded-For: 10.0.0.1
Enter fullscreen mode Exit fullscreen mode

If a gateway in front of an internal admin panel used RemoteAddr=10.0.0.0/8 as an allowlist, that request sailed through — because the check trusted the header, not the connection.

In 4.0.5 the semantics are split cleanly:

  • RemoteAddr matches the pure TCP peer — ctx.remoteAddress().host() — and trusts no client header. If there is no peer address it returns false. This is the one to use for IP allowlists on internet-facing gateways.
  • XForwardedRemoteAddrPredicateFactory matches ctx.realIp() and is the explicit opt-in when you have a trusted proxy chain in front (Nginx, cloud LB) that rewrites the headers for you.
# route config — CIDR notation, same syntax for both
# RemoteAddr=192.168.1.1/24            → peer socket IP only
# XForwardedRemoteAddr=192.168.1.1/24  → trusts X-Forwarded-For (opt-in)
Enter fullscreen mode Exit fullscreen mode

The new factory even guards against a subtle failure: if the header value is a hostname rather than an IP literal, it fails the match instead of triggering a synchronous DNS lookup on the event loop.

A small ecosystem echo

The gateway's websocket.pingInterval: 30 (heartbeat every 30s, ≤0 disables) pairs nicely with the main framework's 4.0.5 addition of sendPing() / sendPong() on the WebSocket API — you can now configure the gateway to heartbeat upstream WebSocket connections, and application handlers can answer pings explicitly. Two repos, one heartbeat story.

Upgrade checklist

  1. Audit every RemoteAddr route — if it relied on spoofable X-Forwarded-For, switch to XForwardedRemoteAddr (trusted proxy chain) or move the allowlist check to the trusted edge.
  2. Tighten timeouts — the default response timeout already dropped 1800s → 60s; set explicit values per route rather than inheriting.
  3. Right-size the pool — start from maxConnections ≈ QPS × avg response seconds and keep maxWaitQueueSize bounded so overload returns 503 fast instead of queuing forever.
  4. Trim the factory registrypredicate.* / filter.* off-switches for factories you never use.
  5. Decide your X-Forwarded-For policy — append at internal hops, overwrite at the trusted edge.

Solon Cloud Gateway 4.0.5 doesn't add a new protocol or a new panel — it gives you back control over the machinery that was already running your traffic, and it quietly closes the spoofed-IP gap that the old RemoteAddr behavior left open. That second part alone is worth the upgrade.

(Part of the Solon v4.0.5 release series — see also the solon-ai 4.0.5 HTTP-customization article.)

Top comments (0)