# Patch Analysis: CVE-2026-57947 — Pinpoint Webhook SSRF

## Fix Commit(s)

The fix is on the Pinpoint `master` branch (no `v3.1.1` tag was available at analysis time). The two relevant commits are:

- `f683a24b4f` — `[#13857] Add webhook SSRF validation` (the primary fix)
- `83d1228185` — `[#13857] Remove user group from webhook payload` (data-exposure mitigation)

Latest tested master ref: `f59a5468ae` (includes both commits).
Vulnerable ref: `v3.1.0` (`e3940e516c`).

Repository: `https://github.com/pinpoint-apm/pinpoint.git`

## What the Fix Changes

### 1. Registration-time validation (`webhook` module)

**Before:** `WebhookController.validateURL()` only did syntactic URL validation:
```java
URL u = new URL(webhook.getUrl());
webhook.setUrl(u.toURI().toString());
```
Any scheme accepted by `java.net.URL` was accepted, including `file://`, `ftp://`, and `http://127.0.0.1`.

**After:** `WebhookController.validateURL()` now delegates to `WebhookUrlValidator.validate()` (`webhook/src/main/java/com/navercorp/pinpoint/web/webhook/support/WebhookUrlValidator.java`). This new validator:
- Parses the URL with `URI.parseServerAuthority()` and rejects malformed input.
- Restricts the scheme to `http` or `https`.
- Rejects user-info (`user:pass@...`) and fragments (`#...`).
- Validates the host literal without resolving DNS:
  - Blocks `localhost`, `*.localhost`, `local`, `*.local`.
  - Parses IPv4/IPv6 literals and rejects loopback, link-local, site-local, multicast, and a wide set of RFC-defined special-use ranges (0.0.0.0/8, 10/8, 127/8, 100.64/10, 169.254/16, 172.16/12, 192.168/16, 192.0.0/24, 192.0.2/24, 192.88.99/24, 198.18/15, 198.51.100/24, 203.0.113/24, 224/4, 240/4, and IPv6 equivalents including NAT64 translations).
- Rejects explicit port `0` and ports > 65535.
- Uses the `ipaddress` library (v5.5.1) for robust CIDR containment checks.

### 2. Send-time validation (`batch-alarmsender` module)

**Before:** `WebhookSenderImpl.sendWebhook()` called `validateURL()` (same `URL → URI` check) and then `restTemplate.exchange(url, HttpMethod.POST, ...)` using the generic `RestTemplate` bean.

**After:** `WebhookSenderImpl` uses a dedicated `webhookRestTemplate` bean constructed from a custom `CloseableHttpClient`:
- `AlarmSenderConfiguration.webhookHttpClient()` builds an Apache HttpClient 5 client with:
  - A custom `WebhookDnsResolver` wrapping `SystemDefaultDnsResolver`.
  - `disableRedirectHandling()` so 30x redirects are not followed.
  - Connection/read timeouts (3s / 6s).
- `WebhookSenderImpl.sendWebhooks()` calls `WebhookUrlValidator.validateSyntax()` before each request. This performs only syntactic validation (no DNS resolution), but the actual HTTP request goes through the custom HTTP client.
- `WebhookDnsResolver.resolve()` resolves the hostname and then validates **every** returned `InetAddress` using `WebhookUrlValidator.validateResolvedAddress()`. If any resolved address is private/local, it throws `UnknownHostException` with a "non-public address" message, aborting the connection before any bytes are sent.

### 3. Payload reduction (`batch-alarmsender` module)

**Before:** The webhook POST body included a `UserGroup` object with member details (`name`, `email`, `department`, `phoneNumber`, `phoneCountryCode`).

**After:** The `UserGroup`/`UserMember` payload classes are removed. The webhook body now only contains alarm metadata (application, checker, threshold, sequence count, pinpoint URL). This removes the data-exfiltration vector even if a request were to reach an attacker-controlled endpoint.

## What Assumptions the Fix Makes

1. **DNS is the authoritative boundary.** The patch assumes that any private/internal target must be reached via a hostname or IP that resolves to a private address. It blocks the resolved IP at the HTTP client layer, which is the last moment before the actual TCP connection.
2. **All webhook requests go through `webhookRestTemplate`.** The fix assumes that the `WebhookSenderImpl` is the only component that sends webhooks and that it always uses the `@Qualifier("webhookRestTemplate")` bean. No other `RestTemplate` or raw HTTP client is used for webhook delivery.
3. **Apache HttpClient 5 always calls the configured `DnsResolver`.** The `WebhookDnsResolver` is registered as the DNS resolver. The patch assumes HttpClient 5 invokes it for every request, including numeric IP literals and decimal/octal IP representations. The upstream unit test `AlarmSenderConfigurationTest` verifies this for `127.0.0.1`, but does not explicitly test every alternate IP encoding or hostname trick.
4. **The registration validator and the DNS resolver use the same blocklist.** Both use `WebhookUrlValidator`, so a literal blocked at registration should also be blocked at resolution time, and vice versa. The one exception is that registration validates without DNS, so a hostname that resolves to a private IP is accepted at registration but rejected at send time.
5. **Operators do not re-introduce a vulnerable HTTP client.** The fix relies on the Spring bean configuration. If an operator or custom build overrides `AlarmSenderConfiguration` to inject a plain `RestTemplate`, protection is lost.

## What the Fix Does NOT Cover (or Where Bypasses Were Sought)

| # | Candidate bypass | Why it is not a true bypass |
|---|--------------------|-----------------------------|
| 1 | Decimal IP `http://2130706433/` (127.0.0.1) | `WebhookUrlValidator.validate()` parses it as a loopback IP and rejects it at registration. |
| 2 | Octal IP `http://0177.0.0.1/` | Same as above; `IPAddressString` normalizes it to 127.0.0.0/8 and rejects. |
| 3 | Hex IP `http://0x7f000001/` | Same as above. |
| 4 | Short IPv4 `http://127.1/`, `http://127.0.1/` | `URI.parseServerAuthority()` rejects these as malformed hostnames. |
| 5 | IPv6 short forms `[::1]`, `[::127.0.0.1]`, NAT64 `[64:ff9b::7f00:1]` | All parsed as loopback/link-local and rejected at registration or DNS resolution. |
| 6 | IPv6 zone IDs `[fe80::1%25lo0]`, `[::1%25lo0]` | `URI.parseServerAuthority()` accepts them; `toInetAddress()` returns null and the validator rejects with "resolved address is required". |
| 7 | DNS-rebinding hostnames (`127.0.0.1.nip.io`, `127-0-0-1.nip.io`, `127.0.0.1.xip.io`) | Accepted at registration because the validator does not resolve DNS, but `WebhookDnsResolver` resolves the hostname to 127.0.0.1 at send time and blocks. |
| 8 | Hostnames containing `localhost` but not ending in `.localhost` (`localhost.example.com`, `example.localhost.com`) | Accepted at registration, but if they resolve to a private IP the DNS resolver blocks. If they resolve to a public IP they are allowed, which is expected. |
| 9 | Credentials `http://user:pass@8.8.8.8/` | Rejected by `validateAuthority()` (user info not allowed). |
| 10 | Fragments `http://8.8.8.8/#frag` | Rejected by `validateAuthority()` (fragment not allowed). |
| 11 | Non-HTTP schemes (`ftp://`, `file://`) | Rejected by scheme validation. |
| 12 | Alternate entry points (`/api/alarmRule/includeWebhooks`) | This endpoint only links existing webhook IDs to alarm rules; it never sets a webhook URL. The URL must still be inserted through the validated webhook endpoints. |

## Is the Fix Complete?

For the **reported SSRF surface** (authenticated users registering webhook URLs that the alarm batch job then POSTs to), the fix is **comprehensive**. It provides defense-in-depth:
- Strict registration-time validation catches most obvious private-target URLs.
- DNS-resolution-time validation catches hostnames and DNS-rebinding attacks that pass registration.
- Redirect disabling prevents a public URL from redirecting to a private target.
- Payload reduction removes the secondary data-exfiltration impact.

No true bypass was found among the tested variant candidates. The only URLs that are **accepted at registration** but still reach a private target are hostnames that resolve to private IPs (e.g., `*.nip.io` or attacker-controlled DNS). These are **blocked at connection time** by the `WebhookDnsResolver`, so the server never sends a request to the private IP.

### Residual gaps (theoretical, not demonstrated)

1. **Registration-time acceptance of private-resolving hostnames.** An attacker can still store a webhook whose hostname points to a private IP. While the request is blocked, the persisted record itself may be considered a policy violation in some threat models. A stricter fix could resolve the hostname at registration time as well (with a short timeout) and reject private-resolving hosts before storing.
2. **Dependency on custom DNS resolver being invoked.** The protection relies on Apache HttpClient 5 calling the custom resolver for every request. If a future refactor uses a different HTTP client, Java 11+ `HttpClient`, or a raw `Socket`, the resolver may be bypassed.
3. **Custom/alternate Spring configuration.** A deployment that overrides `AlarmSenderConfiguration` or injects a different `RestTemplate` into `WebhookSenderImpl` could re-introduce the vulnerability.
4. **No egress logging/alerting.** The patch rejects malicious URLs silently (log warnings). It does not raise a security alert or audit event when an authenticated user attempts to register a blocked URL.

## Behavior Comparison: Before vs. After

| Input | v3.1.0 (vulnerable) | Patched (`f683a24b4f`) |
|-------|---------------------|------------------------|
| `http://127.0.0.1/` | Accepted, sent | Rejected at registration |
| `http://169.254.169.254/...` | Accepted, sent | Rejected at registration |
| `http://localhost/` | Accepted, sent | Rejected at registration |
| `http://127.0.0.1.nip.io/` | Accepted, sent | Accepted at registration, **blocked at DNS resolution** |
| `http://2130706433/` | Accepted, sent | Rejected at registration |
| `http://[::ffff:127.0.0.1]/` | Accepted, sent | Rejected at registration |
| `http://user:pass@8.8.8.8/` | Accepted, sent | Rejected at registration |
| `ftp://8.8.8.8/` | Accepted | Rejected (scheme) |
| `http://8.8.8.8/` | Accepted, sent | Accepted, sent |
| Redirect `http://public.example.com/` → `http://127.0.0.1/` | Followed, sent to private target | Redirects disabled |

## Threat Model Scope

Pinpoint does not publish a `SECURITY.md` or a formal threat model. The issue was tracked as GitHub issue #13857 and closed against the (then) 3.1.1 milestone. The fix treats webhook URLs as attacker-controlled input that must not be allowed to reach internal infrastructure, which is the standard SSRF remediation posture. No out-of-scope claim was found that would invalidate the SSRF report or the bypass search.

## Conclusion

The fix is **effective** for the reported vulnerability. The tested variant matrix (alternative IP encodings, DNS-rebinding hostnames, IPv6 tricks, alternate entry points) did not produce a true bypass. The recommended hardening step is to also resolve and validate hostnames at registration time, eliminating the remaining "store-but-never-send" edge case and reducing the risk of a future code path bypassing the custom DNS resolver.
