Network Boundary Security and Safe Egress
Learn Java Networking - Part 026
Network boundary security and safe egress for Java applications, covering SSRF prevention, URL canonicalization, allowlists, DNS rebinding, private IP filtering, redirect safety, proxy boundaries, and production-grade outbound client design.
Part 026 — Network Boundary Security and Safe Egress
Core thesis: an outbound network call is not a harmless utility function. It is a capability to make your server reach something. If the destination is influenced by untrusted input, it becomes a security boundary.
This part focuses on networking-specific security for Java applications. It does not repeat general cryptography, authentication, or application-security material from earlier series. The narrow focus here is safe outbound connectivity, especially SSRF-class risk, DNS rebinding, redirect abuse, proxy escape, metadata endpoint exposure, and internal-network access.
A production Java service should not let arbitrary input become arbitrary egress.
The goal is not “sanitize the URL”. The goal is:
Only explicitly authorized network destinations are reachable, under stable identity, with predictable scheme, host, port, DNS, redirects, and proxy behavior.
1. Kaufman Skill Map
1.1 Target capability
After this part, you should be able to design Java networking code that:
- treats outbound calls as privileged actions;
- rejects unsafe URL schemes, hosts, ports, redirects, and IP ranges;
- distinguishes parsing, canonicalization, resolution, authorization, and connection;
- understands DNS rebinding and time-of-check/time-of-use risk;
- avoids
URLparsing and equality traps in security decisions; - validates every redirect hop;
- uses allowlists by business capability, not raw user-controlled URLs;
- integrates app-level controls with egress proxy/firewall/service-mesh policy;
- produces audit logs that explain why an outbound destination was allowed or denied.
1.2 Subskills
| Subskill | Why it matters | Practice target |
|---|---|---|
| Threat model egress | Outbound calls can reach internal assets | Draw what the server can reach that the user cannot |
| Canonicalize URI | Attackers exploit parser ambiguity | Parse once and validate normalized components |
| Enforce allowlist | Deny-by-default is safer than blocklist | Map operation to approved destination templates |
| Resolve defensively | DNS can change over time | Validate all resolved addresses and revalidate redirects |
| Classify addresses | Private, loopback, link-local, metadata endpoints matter | Block unsafe CIDRs and special addresses |
| Control redirects | Redirects move destination after approval | Disable auto-redirect or manually validate each hop |
| Use egress proxy | App validation is not enough | Route outbound traffic through enforcement point |
| Audit decisions | Security controls need explainability | Log policy id, normalized target, decision, reason |
2. Threat Model: What Are We Defending Against?
The classic risk is Server-Side Request Forgery: an attacker causes your server to send a request to a destination chosen or influenced by the attacker.
This matters because your server often has access that the attacker does not:
- private VPC services;
- Kubernetes service DNS;
- cloud metadata endpoints;
- localhost admin ports;
- Redis, Elasticsearch, Prometheus, admin dashboards;
- internal HTTP APIs;
- partner APIs with network allowlisting;
- credentials attached by the server;
- mTLS identity from the workload;
- egress NAT IP trusted by other systems.
2.1 Typical vulnerable feature shapes
| Feature | Risky input |
|---|---|
| “Import from URL” | user supplies arbitrary URL |
| Webhook tester | user controls callback target |
| Image fetcher | user controls image URL |
| PDF generator | HTML references remote resources |
| Link preview | service fetches URL to extract metadata |
| File scanner | service pulls remote file |
| Federation client | remote server provides next-hop location |
| OAuth/OIDC metadata fetch | issuer/discovery URL misconfigured or attacker-controlled |
2.2 What attackers try to reach
| Target | Example pattern | Why dangerous |
|---|---|---|
| Localhost | 127.0.0.1, [::1], localhost | Admin ports bound locally |
| Link-local | 169.254.169.254, fe80::/10 | Cloud metadata or local network services |
| Private IPv4 | 10/8, 172.16/12, 192.168/16 | Internal services |
| Unique-local IPv6 | fc00::/7 | Internal IPv6 services |
| Kubernetes DNS | *.svc, service names | Cluster-internal APIs |
| Unix socket bridge | local proxy exposed on TCP | container runtime/admin APIs |
| Internal proxy | open proxy or CONNECT abuse | pivot point |
3. Security Invariants
Use these as architecture review rules.
3.1 Egress invariants
- User input must not directly become a network destination.
- The default decision is deny.
- Allowed destinations are tied to a business capability.
- Scheme, host, port, and path constraints are explicit.
- Unsafe IP ranges are blocked even if DNS name looks harmless.
- Redirects are disabled or revalidated at every hop.
- DNS resolution is not trusted as a one-time permanent fact.
- App-level controls are backed by network-level controls.
- Credentials are attached only after destination approval.
- The decision is auditable.
3.2 Parsing invariants
- Parse with
URI, notURL, for security decisions. - Reject malformed, opaque, relative, or ambiguous URIs.
- Reject userinfo in authority.
- Normalize host casing and IDN representation.
- Enforce scheme allowlist.
- Enforce port allowlist.
- Reject fragments for outbound fetch policy.
- Reconstruct canonical target from parsed components rather than trusting raw string.
3.3 DNS/address invariants
- Resolve host only after syntactic validation.
- Validate all resolved addresses.
- Reject if any resolved address is unsafe unless policy explicitly allows it.
- Revalidate after redirects.
- Consider DNS rebinding and TOCTOU between validation and connection.
- Prefer a controlled egress proxy for strict enforcement.
4. URL vs URI in Java
Java has both java.net.URL and java.net.URI. For security decisions, prefer URI.
Why:
URIis primarily syntactic and does not imply opening a connection;URLrepresents a locator with protocol handlers;URL.equalsandURL.hashCodehistorically may involve host name resolution, which is inappropriate for parsing, maps, and security decisions;- URL parsing behavior can differ from modern browser expectations.
Use this shape:
URI uri = URI.create(rawInput);
Then validate components.
Avoid this for security policy:
URL url = new URL(rawInput); // not the right policy primitive
boolean allowed = allowedUrls.contains(url);
4.1 Reject ambiguous forms
A safe egress parser should reject:
http://user:pass@example.com/
//example.com/path
http:example.com
file:///etc/passwd
gopher://example.com/
http://example.com#@internal
http://example.com:999999/
http://[::1]/
Do not “fix up” attacker input. Reject it.
5. Safe Egress Pipeline
A production-safe outbound request should pass through a pipeline.
The important ordering:
Do not attach credentials before the destination is authorized.
6. Allowlist Design
A blocklist is fragile because attackers have many ways to encode the same destination.
Prefer allowlists.
6.1 Bad allowlist
boolean allowed = rawUrl.startsWith("https://api.partner.com");
Bypass candidates include parser ambiguity, subdomain tricks, userinfo, redirects, and path confusion.
6.2 Better allowlist
Model a destination as structured policy:
record EgressDestinationPolicy(
String id,
Set<String> schemes,
Set<String> exactHosts,
Set<Integer> ports,
List<String> pathPrefixes,
boolean allowRedirects,
long maxResponseBytes
) {}
Then compare parsed components, not strings.
6.3 Best allowlist: capability-based
Do not ask callers for a full URL when the destination is known.
Bad API:
fetch(String url)
Better API:
fetchPartnerInvoice(PartnerId partnerId, InvoiceId invoiceId)
The client builds the URL internally from trusted configuration:
URI uri = URI.create("https://api.partner.example/v1/invoices/" + encode(invoiceId));
The caller controls business identifiers, not network coordinates.
6.4 Allowlist patterns
| Pattern | Use when | Notes |
|---|---|---|
| Exact host allowlist | Fixed partner APIs | Strong default |
| Suffix allowlist | Controlled subdomains | Require dot-boundary: .example.com, not string suffix only |
| Service registry ID | Internal service calls | Prefer service identity over user URL |
| Egress proxy route | Enterprise-controlled egress | Centralizes enforcement |
| Signed callback URL | Temporary controlled callback | Include expiry, audience, and path constraints |
7. Host Validation
7.1 Normalize host
Host comparison should account for:
- case-insensitivity;
- trailing dot in DNS names;
- IDN/punycode;
- IPv6 bracket syntax;
- IPv4 literals;
- percent encoding not allowed in host after parsing.
Example helper:
static String normalizeDnsHost(String host) {
if (host == null || host.isBlank()) {
throw new IllegalArgumentException("missing host");
}
String withoutTrailingDot = host.endsWith(".")
? host.substring(0, host.length() - 1)
: host;
String ascii = IDN.toASCII(withoutTrailingDot, IDN.USE_STD3_ASCII_RULES);
return ascii.toLowerCase(Locale.ROOT);
}
7.2 Exact host match
static boolean exactHostAllowed(String host, Set<String> allowedHosts) {
String normalized = normalizeDnsHost(host);
return allowedHosts.contains(normalized);
}
7.3 Safe suffix match
Bad:
host.endsWith("example.com")
This permits evil-example.com.
Better:
static boolean isSubdomainOf(String host, String domain) {
String h = normalizeDnsHost(host);
String d = normalizeDnsHost(domain);
return h.equals(d) || h.endsWith("." + d);
}
Use suffix allowlists only for domains you control.
8. Port and Scheme Policy
8.1 Scheme allowlist
For typical HTTP clients:
allowed schemes: https
maybe allowed: http for explicitly internal non-sensitive local development
blocked: file, jar, ftp, gopher, dict, ldap, mailto, data, javascript
Do not pass arbitrary schemes to protocol handlers.
8.2 Port allowlist
static int effectivePort(URI uri) {
if (uri.getPort() != -1) return uri.getPort();
return switch (uri.getScheme().toLowerCase(Locale.ROOT)) {
case "https" -> 443;
case "http" -> 80;
default -> throw new IllegalArgumentException("unsupported scheme");
};
}
Common safe policy:
https: 443 only
http: 80 only, if allowed at all
Avoid allowing arbitrary ports unless the business capability requires them.
9. Address Classification
Host allowlists are not enough. DNS names resolve to IP addresses, and IP addresses have semantics.
You must reject unsafe address ranges for untrusted outbound fetch.
9.1 Unsafe destination categories
| Category | Examples |
|---|---|
| Loopback | 127.0.0.0/8, ::1/128 |
| Private IPv4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| Link-local IPv4 | 169.254.0.0/16 |
| Link-local IPv6 | fe80::/10 |
| Unique-local IPv6 | fc00::/7 |
| Unspecified | 0.0.0.0, :: |
| Multicast | 224.0.0.0/4, ff00::/8 |
| IPv4-mapped IPv6 | ::ffff:127.0.0.1 style |
| Cloud metadata | commonly 169.254.169.254 and provider-specific names |
9.2 Java InetAddress helpers
InetAddress provides useful methods:
static boolean isObviouslyUnsafe(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress();
}
But do not rely only on this for full SSRF defense. Maintain explicit CIDR ranges for your policy, especially for provider-specific metadata endpoints and IPv6 ranges.
9.3 Validate all resolved addresses
static List<InetAddress> resolveAndValidateAll(String host, Predicate<InetAddress> allowed) throws UnknownHostException {
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length == 0) {
throw new UnknownHostException("no addresses for " + host);
}
List<InetAddress> result = new ArrayList<>();
for (InetAddress address : addresses) {
if (!allowed.test(address)) {
throw new SecurityException("unsafe resolved address: " + address.getHostAddress());
}
result.add(address);
}
return List.copyOf(result);
}
Why all addresses?
If a hostname resolves to both safe and unsafe addresses, the connection path may choose an unsafe one depending on address preference, retries, IPv4/IPv6 behavior, or runtime/network configuration.
10. DNS Rebinding and TOCTOU
DNS rebinding exploits the gap between:
validation time: host resolves to safe public IP
connection time: host resolves to internal/private IP
This is a time-of-check/time-of-use problem.
10.1 Weak approach
InetAddress address = InetAddress.getByName(host);
if (isPublic(address)) {
httpClient.send(request, handler); // may resolve again internally later
}
Your validation resolution may not be the same resolution used by the HTTP client connection.
10.2 Stronger approaches
| Approach | Strength | Notes |
|---|---|---|
| Exact host allowlist for trusted domains | High | Best for partner APIs |
| Egress proxy/firewall enforcement | Very high | Blocks unsafe IPs regardless of app bugs |
| Disable arbitrary URL fetch | Highest | Use capability-specific APIs |
| Custom DNS resolver / connect layer | High but complex | Hard with high-level HttpClient |
| Revalidate redirects and resolved addresses | Necessary but not sufficient alone | Reduces common SSRF paths |
The practical enterprise answer is layered:
application allowlist + safe parser + redirect validation + egress proxy/firewall + metadata endpoint protection
11. Redirect Safety
Redirects are destination changes.
If you validate only the first URL but automatically follow redirects, the actual request may go elsewhere.
11.1 Dangerous
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
Dangerous for untrusted destinations because Location may point to internal networks or unsafe schemes.
11.2 Safer
Use no automatic redirects, then manually validate each hop.
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(3))
.build();
Manual loop:
static HttpResponse<InputStream> sendWithValidatedRedirects(
HttpClient client,
URI initial,
SafeEgressPolicy policy,
int maxRedirects
) throws IOException, InterruptedException {
URI current = initial;
for (int i = 0; i <= maxRedirects; i++) {
URI safe = policy.validate(current);
HttpRequest request = HttpRequest.newBuilder(safe)
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status >= 300 && status < 400) {
response.body().close();
Optional<String> location = response.headers().firstValue("Location");
if (location.isEmpty()) {
throw new IOException("redirect without Location");
}
current = safe.resolve(location.get());
continue;
}
return response;
}
throw new IOException("too many redirects");
}
Each redirect target goes back through the full policy.
12. Credentials and Destination Approval
Never attach sensitive headers before destination validation.
Bad:
HttpRequest request = HttpRequest.newBuilder(userUri)
.header("Authorization", "Bearer " + token)
.GET()
.build();
Better:
URI safeUri = policy.validate(userUri);
HttpRequest request = HttpRequest.newBuilder(safeUri)
.header("Authorization", "Bearer " + token)
.GET()
.build();
Better still: avoid user URI entirely and map a business operation to a configured destination.
12.1 Credential scope rule
| Credential | Destination rule |
|---|---|
| Partner API token | exact partner API host only |
| Internal service mTLS identity | internal service identity only |
| User OAuth token | resource server audience only |
| Basic auth | never sent across redirect unless same approved origin |
| Cookie | controlled cookie jar per approved origin |
13. Proxy, Firewall, and Service Mesh Boundary
Application validation is necessary but insufficient.
A robust network boundary uses multiple enforcement layers:
13.1 Why app-only control fails
- A future code path may bypass your wrapper.
- A library may make its own HTTP call.
- DNS rebinding may exploit client internals.
- Redirect behavior may change.
- Proxy configuration may differ across environments.
- Emergency patches may introduce raw URL fetches.
13.2 Egress proxy benefits
- central allowlist;
- mTLS enforcement;
- DNS policy;
- audit logging;
- block private/link-local ranges;
- outbound rate limits;
- consistent behavior across languages.
13.3 App still matters
The app should still validate because:
- it knows business intent;
- it can reject earlier and more clearly;
- it can avoid credential leakage;
- it can classify error reasons;
- it can enforce payload limits and redirect policy.
14. Safe Egress Policy Skeleton
Below is a simplified skeleton. Real production code should use tested CIDR utilities and extensive test cases.
public final class SafeEgressPolicy {
private final Set<String> allowedSchemes = Set.of("https");
private final Set<String> allowedHosts;
private final Set<Integer> allowedPorts = Set.of(443);
public SafeEgressPolicy(Set<String> allowedHosts) {
this.allowedHosts = allowedHosts.stream()
.map(SafeEgressPolicy::normalizeDnsHost)
.collect(Collectors.toUnmodifiableSet());
}
public URI validate(URI input) throws UnknownHostException {
if (!input.isAbsolute() || input.isOpaque()) {
throw new SecurityException("URI must be absolute and hierarchical");
}
String scheme = requireLower(input.getScheme(), "scheme");
if (!allowedSchemes.contains(scheme)) {
throw new SecurityException("scheme not allowed: " + scheme);
}
if (input.getRawUserInfo() != null) {
throw new SecurityException("userinfo is not allowed");
}
if (input.getRawFragment() != null) {
throw new SecurityException("fragment is not allowed");
}
String host = normalizeDnsHost(input.getHost());
if (!allowedHosts.contains(host)) {
throw new SecurityException("host not allowed: " + host);
}
int port = effectivePort(input);
if (!allowedPorts.contains(port)) {
throw new SecurityException("port not allowed: " + port);
}
validateResolvedAddresses(host);
return canonicalize(input, scheme, host, port);
}
private static String requireLower(String value, String field) {
if (value == null || value.isBlank()) {
throw new SecurityException("missing " + field);
}
return value.toLowerCase(Locale.ROOT);
}
private static String normalizeDnsHost(String host) {
if (host == null || host.isBlank()) {
throw new SecurityException("missing host");
}
String h = host.endsWith(".") ? host.substring(0, host.length() - 1) : host;
return IDN.toASCII(h, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
}
private static int effectivePort(URI uri) {
if (uri.getPort() != -1) return uri.getPort();
return switch (uri.getScheme().toLowerCase(Locale.ROOT)) {
case "https" -> 443;
case "http" -> 80;
default -> throw new SecurityException("unsupported scheme");
};
}
private static void validateResolvedAddresses(String host) throws UnknownHostException {
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length == 0) {
throw new UnknownHostException(host);
}
for (InetAddress address : addresses) {
if (isUnsafe(address)) {
throw new SecurityException("unsafe resolved address: " + address.getHostAddress());
}
}
}
private static boolean isUnsafe(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()
|| isCloudMetadataAddress(address)
|| isUniqueLocalIpv6(address);
}
private static boolean isCloudMetadataAddress(InetAddress address) {
return address.getHostAddress().equals("169.254.169.254");
}
private static boolean isUniqueLocalIpv6(InetAddress address) {
byte[] bytes = address.getAddress();
return bytes.length == 16 && (bytes[0] & (byte) 0xfe) == (byte) 0xfc;
}
private static URI canonicalize(URI input, String scheme, String host, int port) {
try {
int explicitPort = switch (scheme) {
case "https" -> port == 443 ? -1 : port;
case "http" -> port == 80 ? -1 : port;
default -> port;
};
return new URI(
scheme,
null,
host,
explicitPort,
input.getRawPath() == null || input.getRawPath().isBlank() ? "/" : input.getRawPath(),
input.getRawQuery(),
null
);
} catch (URISyntaxException e) {
throw new SecurityException("failed to canonicalize URI", e);
}
}
}
This skeleton demonstrates structure, not final completeness. Production code should include:
- CIDR range library;
- IPv4-mapped IPv6 handling;
- provider-specific metadata hostnames;
- redirect tests;
- IDN tests;
- parser-confusion tests;
- proxy integration;
- complete audit logging;
- centralized configuration.
15. Special Cases
15.1 Cloud metadata endpoints
Many cloud environments expose metadata through link-local addresses or special hostnames.
Treat metadata access as its own privileged capability.
Rules:
- block link-local destinations for arbitrary fetch;
- block well-known metadata IPs and hostnames;
- configure cloud metadata protections where available;
- do not let generic HTTP clients reach metadata endpoints;
- alert on denied attempts.
15.2 Kubernetes and service DNS
Inside Kubernetes, names like these may resolve internally:
service
service.namespace
service.namespace.svc
service.namespace.svc.cluster.local
A URL fetcher that seems safe in local development may become dangerous inside a cluster.
Policy should be environment-aware and enforced at network level.
15.3 Localhost is not safe
localhost can expose:
- admin endpoints;
- metrics with secrets;
- local proxies;
- database tunnels;
- sidecar APIs;
- debug ports.
Reject loopback unless the specific business capability requires it and is isolated.
15.4 Private IPs are not always obviously private
Attackers may use:
- decimal IPv4 forms;
- octal-like notations in some parsers;
- hexadecimal notation in some contexts;
- IPv4-mapped IPv6;
- DNS names resolving to private IPs;
- redirects to private IPs;
- CNAME chains.
Use strict parser behavior and address-based validation after DNS resolution.
16. Response Handling Is Also Security
Safe egress is not only about destination. Response handling matters too.
Apply limits from Part 025:
- max response bytes;
- max header size where server/framework permits;
- max redirects;
- max decompressed size;
- content-type allowlist;
- streaming instead of full buffering;
- no raw internal error response passthrough;
- malware/scanner boundary if importing files.
16.1 Decompression bomb risk
If you accept compressed responses, the compressed byte size may be small while decompressed content is huge.
Policy should define:
maxCompressedBytes
maxDecompressedBytes
allowedContentEncodings
16.2 Content type is not trust
Do not trust Content-Type alone. It is a hint. Validate actual content if it crosses security boundaries.
17. Observability and Audit
Security controls must be debuggable without leaking secrets.
17.1 Decision log fields
Log:
policyId
operation
normalizedScheme
normalizedHost
effectivePort
pathPolicyMatched
resolvedAddressClasses
decision
reason
callerService
traceId
Avoid logging:
- full query strings with secrets;
- authorization headers;
- cookies;
- raw user-submitted URLs when they may contain credentials;
- response bodies.
17.2 Metrics
| Metric | Meaning |
|---|---|
egress.policy.allowed | approved outbound attempts |
egress.policy.denied | denied outbound attempts |
egress.policy.denied.reason | cardinality-controlled reason tag |
egress.redirect.denied | redirect blocked by policy |
egress.resolution.unsafe | DNS resolved to unsafe address |
egress.response.too_large | response exceeded byte cap |
egress.destination.host | use low-cardinality grouping, not raw arbitrary hosts |
18. Test Matrix
A safe egress wrapper needs adversarial tests.
18.1 URI parsing tests
| Input | Expected |
|---|---|
https://api.partner.example/resource | allow if host is configured |
http://api.partner.example/resource | deny if only HTTPS allowed |
file:///etc/passwd | deny |
https://user:pass@api.partner.example/ | deny |
https://api.partner.example:444/ | deny unless port allowed |
//api.partner.example/path | deny relative network-path reference |
https://api.partner.example#fragment | deny or strip only by explicit policy |
18.2 Host confusion tests
| Input | Expected |
|---|---|
https://evil-api.partner.example.attacker.com/ | deny |
https://api.partner.example./ | normalize and evaluate |
https://API.PARTNER.EXAMPLE/ | normalize and evaluate |
https://xn--.../ | IDN policy applied |
https://127.0.0.1/ | deny |
https://[::1]/ | deny |
https://169.254.169.254/ | deny |
18.3 DNS tests
| Scenario | Expected |
|---|---|
| allowed host resolves to public IP | allow |
| allowed host resolves to private IP | deny |
| allowed host resolves to mixed public/private IPs | deny |
| DNS changes between calls | revalidation catches unsafe result where possible |
| CNAME chain ends in unsafe IP | deny based on final addresses |
18.4 Redirect tests
| Scenario | Expected |
|---|---|
| redirect to same approved host | allow if redirects enabled |
| redirect from approved host to private IP | deny |
| redirect from HTTPS to HTTP | deny unless policy allows downgrade |
| redirect with userinfo | deny |
| redirect loop | fail at max redirect count |
19. Architecture Decision Record Template
Use this ADR shape for safe egress decisions.
# ADR: Safe Egress Policy for <Capability>
## Context
The service needs to call <destination> for <business purpose>.
The destination may be influenced by <trusted config / user input / partner data>.
## Decision
- Allowed schemes:
- Allowed hosts:
- Allowed ports:
- Redirect policy:
- DNS/address policy:
- Credential attachment point:
- Response size limit:
- Proxy/egress gateway:
- Audit fields:
## Rejected Alternatives
- Raw user-provided URL:
- Blocklist-only validation:
- Automatic redirects:
## Failure Behavior
- Unsafe destination:
- DNS failure:
- Redirect denied:
- Response too large:
- Timeout:
## Tests
- Parser confusion:
- Private IP:
- Metadata endpoint:
- Redirect abuse:
- Large response:
This forces security choices to become explicit and reviewable.
20. Anti-Patterns
20.1 “We validate it with regex”
Regex is not a URI parser. Use structured parsing, then policy.
20.2 “We block localhost”
Blocking only localhost misses 127.0.0.1, ::1, private ranges, link-local addresses, DNS rebinding, redirects, and metadata endpoints.
20.3 “The firewall protects us”
Maybe. But the application still may leak credentials, follow unsafe redirects, or generate confusing failures. Use both app and network controls.
20.4 “It is only an image fetcher”
Image fetchers are classic SSRF surfaces. They fetch attacker-chosen URLs and often process large or malformed content.
20.5 “We trust DNS”
DNS is a name-to-address mechanism, not an authorization system.
20.6 “We use HTTPS, so it is safe”
HTTPS protects transport authenticity/confidentiality for the chosen destination. It does not prove the destination is allowed.
21. Production Checklist
21.1 API design
- Does the API avoid accepting arbitrary URLs where possible?
- Is the destination derived from trusted configuration?
- Is the business capability mapped to a named egress policy?
- Are credentials attached only after approval?
21.2 URI validation
- Is
URIused for parsing? - Are relative, opaque, malformed, and userinfo URIs rejected?
- Are scheme and port allowlisted?
- Is host normalized?
- Is suffix matching dot-boundary safe?
21.3 DNS/IP validation
- Are all resolved addresses checked?
- Are loopback, private, link-local, multicast, unspecified, and metadata ranges blocked?
- Are IPv6 and IPv4-mapped forms covered?
- Is DNS rebinding considered?
21.4 Redirects
- Are automatic redirects disabled for untrusted destinations?
- Is every redirect target revalidated?
- Is redirect count bounded?
- Are scheme downgrades denied?
21.5 Network enforcement
- Is there an egress proxy, firewall, service mesh, or network policy?
- Are private and metadata ranges blocked outside the app too?
- Are emergency bypasses audited?
21.6 Response safety
- Is max response size enforced?
- Is decompressed size bounded?
- Is content streamed safely?
- Are raw responses not reflected to users?
21.7 Observability
- Are allow/deny decisions logged safely?
- Are deny reasons metricized?
- Are suspicious attempts alertable?
- Are raw secrets redacted?
22. Deliberate Practice
Drill 1 — Safe URL validator
Implement SafeEgressPolicy.validate(URI) with tests for:
- scheme;
- host;
- port;
- userinfo;
- fragment;
- IDN;
- private IP;
- loopback;
- metadata endpoint;
- redirect target.
Drill 2 — Manual redirect client
Build an HttpClient wrapper that:
- disables automatic redirect;
- validates each
Location; - caps redirect count;
- preserves method policy correctly;
- refuses credential forwarding across origin.
Drill 3 — DNS rebinding lab
Create a test resolver or local DNS setup that returns:
- public IP on first lookup;
- private IP on second lookup.
Verify what your app catches and what only network-level enforcement can catch.
Drill 4 — Egress audit dashboard
Expose:
- top denied policy reasons;
- denied unsafe address classes;
- redirect denials;
- metadata endpoint attempts;
- policy id by operation.
Your goal is to make unsafe egress attempts visible before they become incidents.
23. Mental Model Summary
Safe egress is the discipline of preventing a Java service from becoming an attacker-controlled network pivot.
Remember:
- Outbound network access is a capability.
- User input should not directly become a destination.
- Parse, normalize, authorize, resolve, validate addresses, then connect.
- Validate every redirect as a new destination.
- Block unsafe IP ranges even when the hostname looks acceptable.
- DNS is not authorization.
- App-level validation and network-level enforcement are complementary.
- Credentials are attached only after destination approval.
- Large responses and decompression are also part of egress risk.
- A safe client is a policy boundary, not just a wrapper around
HttpClient.
The senior-engineering question is:
What exact network destinations can this code cause our infrastructure to reach, under whose authority, and what prevents that set from expanding accidentally?
References
- OWASP Cheat Sheet — Server-Side Request Forgery Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Top 10 2021 — A10 Server-Side Request Forgery: https://owasp.org/Top10/2021/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/
- Java SE 25 API —
java.net.URI: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/net/URI.html - Java SE 25 API —
java.net.InetAddress: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/net/InetAddress.html - Java SE 25 API —
java.net.http.HttpClient: https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpClient.html
You just completed lesson 26 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.