September 23, 2026
Stop Writing Regexes for IP Security: SSRF Protection Is a Network Semantics Problem
IP addresses are not strings with dots and colons. Treating them that way is how SSRF defenses develop blind spots.

By Berkan SAL
12 min read
- 1 IP addresses are not strings with dots and colons. Treating them that way is how SSRF defenses develop blind spots.
- 2 The Problem With "Private IP Detection"
- 3 A Comment Can Say /4 While the Code Implements /8
- 4 Prefix Lengths Do Not Map Cleanly to Text Prefixes
- 5 False Negatives Are Only Half the Problem
IP addresses are not strings with dots and colons. Treating them that way is how SSRF defenses develop blind spots.
One of the easiest ways to make a security control fragile is to model the wrong thing.
For SSRF protections, that mistake often looks harmless:
/^127\./
/^10\./
/^192\.168\.//^127\./
/^10\./
/^192\.168\./Maybe some IPv6 prefixes get added later:
ip.startsWith("fe80:")
ip.startsWith("fc")ip.startsWith("fe80:")
ip.startsWith("fc")The code is readable.
The intention is obvious.
There is even usually a comment beside each rule explaining exactly which network range it represents.
And that is precisely what makes this pattern dangerous.
The code appears to describe networks.
But it does not.
It describes text.
While researching webhook URL validation in Formbricks, I found several cases where the textual classifier and actual network semantics had diverged.
My earlier report, tracked internally as ENG-1326, was eventually referenced directly in the merged remediation work. Formbricks' later PR #8946 states that the ranges I reported still reproduced against the existing classifier and credits the original report to me, Berkan SAL (@Uhudsavasindankacanokcu2).
What interested me most, though, was not any one missed IP range.
It was the broader failure mode:
SSRF defenses fail when they classify representations instead of destinations.
This article is about that distinction.
The Problem With "Private IP Detection"
A webhook feature often needs to accept URLs like:
https://customer.example/webhookhttps://customer.example/webhookwhile rejecting destinations such as:
127.0.0.1
10.0.0.5
169.254.169.254
::1127.0.0.1
10.0.0.5
169.254.169.254
::1The objective is simple:
User-controlled URLs must not let an external user reach internal infrastructure.
So developers build an IP classifier.
At a high level:
parse URL
โ
resolve hostname
โ
obtain IP address
โ
is this address internal?
yes โ reject
no โ connectparse URL
โ
resolve hostname
โ
obtain IP address
โ
is this address internal?
yes โ reject
no โ connectThat final question looks trivial.
It is not.
An IP address belongs to a structured address space governed by prefix lengths, protocol semantics, translation mechanisms, special-purpose registries, parser normalization rules, and runtime behavior.
If we instead model it as:
string.startsWith(...)string.startsWith(...)we have replaced networking with pattern matching.
A Comment Can Say /4 While the Code Implements /8
One of the clearest examples in the Formbricks classifier looked conceptually like this:
/^224\./ // 224.0.0.0/4 โ Multicast/^224\./ // 224.0.0.0/4 โ MulticastThe comment says:
224.0.0.0/4224.0.0.0/4But the regular expression says:
first octet == 224first octet == 224Those are not the same set.
224.0.0.0/4 covers:
224.0.0.0
through
239.255.255.255224.0.0.0
through
239.255.255.255while:
/^224\.//^224\./covers only:
224.0.0.0/8224.0.0.0/8So addresses such as:
225.0.0.1
239.255.255.250225.0.0.1
239.255.255.250fell outside the actual check despite being inside the network described by the comment.
The same pattern appeared around the 240.0.0.0/4 range.
This is a perfect example of semantic drift.
The developer is thinking:
CIDRCIDRthe comment documents:
CIDRCIDRbut the executable policy is:
regular expressionregular expressionThe two can silently diverge.
Prefix Lengths Do Not Map Cleanly to Text Prefixes
IPv6 makes this worse.
Consider the link-local range:
fe80::/10fe80::/10A tempting implementation is:
ip.startsWith("fe80:")ip.startsWith("fe80:")But that checks something much narrower.
fe80::/10 is not simply "IPv6 strings beginning with fe80."
The /10 prefix spans addresses through:
febf:...febf:...So valid link-local examples include:
fe9f::1
febf::1fe9f::1
febf::1A literal "fe80:" prefix only captures a small textual subset of the actual network range.
Again, the problem is not an exotic parser trick.
The wrong abstraction was selected.
A CIDR is a bit-prefix relationship.
A string prefix is a character-prefix relationship.
Those are different mathematical objects.
False Negatives Are Only Half the Problem
Security filtering discussions naturally focus on dangerous addresses that get through.
But inaccurate network models can also block legitimate destinations.
The Formbricks classifier included a regex intended to model CGNAT:
100.64.0.0/10100.64.0.0/10A boundary mistake caused public addresses immediately above the actual range, including addresses under:
100.128.0.0/15100.128.0.0/15to be rejected.
So the same class of implementation error produced both:
false negative
โ dangerous destination treated as publicfalse negative
โ dangerous destination treated as publicand:
false positive
โ public destination treated as privatefalse positive
โ public destination treated as privateThat matters because security filters are not successful merely when they block a lot.
They need to accurately implement the intended policy boundary.
A filter that rejects too much becomes operationally painful.
Operational pain creates pressure for:
exceptions
disable flags
allow-all overrides
temporary bypassesexceptions
disable flags
allow-all overrides
temporary bypassesAnd those often become security problems of their own.
The Address You See May Not Be the Address Being Represented
Things get more interesting when IPv6 begins carrying IPv4 semantics.
There are several ways an address can encode or derive from IPv4 space.
Examples include:
IPv4-mapped IPv6
IPv4-compatible IPv6
NAT64
6to4
Teredo
IPv4-translated / SIITIPv4-mapped IPv6
IPv4-compatible IPv6
NAT64
6to4
Teredo
IPv4-translated / SIITNow imagine your security model is:
if IPv4:
run IPv4 private checks
if IPv6:
run IPv6 private checksif IPv4:
run IPv4 private checks
if IPv6:
run IPv6 private checksThat sounds reasonable.
Until the IPv6 address semantically represents an IPv4 destination you intended to block.
For example, forms exist that encode addresses such as:
127.0.0.1
169.254.169.254
10.0.0.1127.0.0.1
169.254.169.254
10.0.0.1inside IPv6 representations.
The security question is not:
Does this string look like an IPv4 address?
It is:
What network destination does this representation denote in the environment where it will be used?
That is a much harder question.
Representation Explosion
Once developers begin manually handling these forms, the classifier tends to grow like this:
normal IPv4
+
private IPv4 regexes
+
IPv6 prefixes
+
IPv4-mapped detection
+
hex conversion
+
special translation range
+
normalization exception
+
parser exceptionnormal IPv4
+
private IPv4 regexes
+
IPv6 prefixes
+
IPv4-mapped detection
+
hex conversion
+
special translation range
+
normalization exception
+
parser exceptionEvery new bypass teaches the filter another spelling.
But the real problem is that it should not have been performing spelling-based security in the first place.
This is what I think of as representation explosion.
The number of ways to express or transport an address grows faster than the hand-written classifier's model of them.
Alternative IPv4 Syntax Is Another Warning Sign
URLs themselves complicate the situation.
A user might provide an address in forms such as:
0x7f000001
2130706433
0177.0.0.1
127.10x7f000001
2130706433
0177.0.0.1
127.1These may look completely different from:
127.0.0.1127.0.0.1Yet the URL parser can normalize them to that address before the network request occurs.
This creates two possible implementation strategies.
The dangerous one:
reimplement every weird IP notation ourselvesreimplement every weird IP notation ourselvesThe stronger one:
let the canonical URL/IP parser normalize syntax,
then perform policy on the normalized addresslet the canonical URL/IP parser normalize syntax,
then perform policy on the normalized addressFormbricks' remediation moved in the latter direction.
The WHATWG URL parser already understands alternative IPv4 forms, so the application does not need to invent another miniature IP parser.
That principle generalizes:
Security code should reuse the same canonical parser semantics as the component that will actually consume the input.
Otherwise validation and execution can disagree.
And disagreement between validator and consumer is one of the oldest sources of security bypasses.
Parser Differential Is Often the Real Vulnerability
Suppose your validator believes:
127.1127.1is a hostname.
But the HTTP stack later interprets it as:
127.0.0.1127.0.0.1Then the application has two different realities:
security layer:
"looks public"
network layer:
"loopback"security layer:
"looks public"
network layer:
"loopback"This pattern exists far beyond IP addresses.
We see the same structure in:
path traversal
URL parsing
Unicode
domain names
HTTP request smuggling
archive extraction
filesystem normalization
SQL parsingpath traversal
URL parsing
Unicode
domain names
HTTP request smuggling
archive extraction
filesystem normalization
SQL parsingThe general form is:
Validator V interprets X one way.
Consumer C interprets X another way.
Security decision is based on V.
Dangerous action is performed by C.Validator V interprets X one way.
Consumer C interprets X another way.
Security decision is based on V.
Dangerous action is performed by C.If:
V(X) โ C(X)V(X) โ C(X)you have an interesting attack surface.
The Family Must Come From the Address
Another subtle failure in the Formbricks implementation involved address family.
Conceptually, the classifier had a function shaped like:
isPrivateIP(ip, family)isPrivateIP(ip, family)where the caller supplied:
family = IPv4family = IPv4or:
family = IPv6family = IPv6That creates a trust boundary you may not immediately notice.
Now imagine the resolver path says:
IPv6 resultIPv6 resultbut returns:
10.0.0.110.0.0.1If the classifier trusts the caller's family metadata instead of parsing the actual returned address, it may run:
10.0.0.110.0.0.1against IPv6 rules.
Nothing matches.
The address is classified as public.
That is a particularly useful security lesson:
Never let metadata select the validator when the validated value can identify its own type.
If the address says it is IPv4, classify it as IPv4.
Do not trust the path that delivered it to tell you what it is.
The remediation derived the family from parsing the actual address.
Unknown Should Not Mean Public
Another dangerous pattern is:
I cannot classify this input
therefore
it is not privateI cannot classify this input
therefore
it is not privateThis often appears unintentionally.
Imagine:
if (blockList.check(address)) {
reject()
}
allow()if (blockList.check(address)) {
reject()
}
allow()What happens if address is malformed?
If the blocklist library simply reports:
not foundnot foundthen malformed data may flow into the same path as a legitimate public address.
From a security perspective:
not blockednot blockedand:
validated public destinationvalidated public destinationare not necessarily equivalent.
For an SSRF boundary, uncertainty should usually move toward rejection:
known public โ maybe allow
known internal โ reject
unparseable โ rejectknown public โ maybe allow
known internal โ reject
unparseable โ rejectThat is ordinary fail-closed design.
But it becomes especially important in network classifiers because malformed data may later be normalized by another layer.
Fix the Abstraction, Not the Individual Regex
A weak remediation would look like this:
add regex for 225โ239
expand fe80 handling
patch CGNAT
add another IPv6 prefix
add another special caseadd regex for 225โ239
expand fe80 handling
patch CGNAT
add another IPv6 prefix
add another special caseThat fixes today's examples.
It does not fix the bug class.
The more interesting part of Formbricks PR #8946 is that the implementation moved to Node's:
net.BlockListnet.BlockListwith explicit CIDR subnet rules.
Instead of:
/^224\.//^224\./the policy can say:
224.0.0.0/4224.0.0.0/4Instead of:
startsWith("fe80:")startsWith("fe80:")it can say:
fe80::/10fe80::/10That matters because the implementation now uses the same abstraction as the policy.
The code says CIDR.
The comment says CIDR.
The runtime evaluates CIDR.
There is less room for semantic drift.
Security Fixes Should Make Bug Classes Structurally Harder
This is a principle I care about more and more:
The best security patches do not merely reject the known payload. They make the vulnerable representation harder to express incorrectly.
Compare:
Patch A:
add three more regexesPatch A:
add three more regexeswith:
Patch B:
replace string matching with actual network-prefix matchingPatch B:
replace string matching with actual network-prefix matchingPatch A adds knowledge.
Patch B changes the model.
Changing the model is usually stronger.
It converts entire families of mistakes from:
easy to accidentally writeeasy to accidentally writeinto:
difficult or impossible to expressdifficult or impossible to expressFor example, once an API accepts:
(address, prefixLength)(address, prefixLength)you no longer have to manually remember how many decimal strings correspond to a /10.
The abstraction carries that meaning.
Boundary Tests Matter More Than Happy-Path Tests
Another part of the remediation I like is the testing strategy.
It did not only test addresses that should be blocked.
It also tested addresses immediately outside blocked ranges.
For example:
100.63.255.255
100.128.0.1
172.32.0.1
223.255.255.255100.63.255.255
100.128.0.1
172.32.0.1
223.255.255.255and IPv6 equivalents around relevant boundaries.
Why is this useful?
Because a security filter has two correctness obligations:
block everything inside the forbidden set
allow everything legitimately outside itblock everything inside the forbidden set
allow everything legitimately outside itTesting only known bad addresses proves almost nothing about an incorrect prefix length.
Imagine intending:
/10/10and accidentally writing:
/9/9All your malicious test cases may still pass.
Only the public boundary cases expose the over-blocking.
So for CIDR security tests, I like the pattern:
first blocked address
middle blocked address
last blocked address
one address below
one address abovefirst blocked address
middle blocked address
last blocked address
one address below
one address aboveThis verifies the shape of the boundary rather than a handful of examples.
The Research Method: Enumerate Semantics, Not Payloads
When I first look at an SSRF classifier, I do not want a giant payload list.
I want to identify every transformation between:
user inputuser inputand:
network connectionnetwork connectionFor example:
raw URL
โ
URL parser
โ
hostname
โ
IP literal detection
โ
DNS
โ
address family classification
โ
private/public policy
โ
connectionraw URL
โ
URL parser
โ
hostname
โ
IP literal detection
โ
DNS
โ
address family classification
โ
private/public policy
โ
connectionThen I ask where representation can change.
Layer 1 โ URL parser
Questions:
Does it normalize alternative IPv4 syntax?
How does it handle IPv6 brackets?
How does it handle zone identifiers?
What does it reject?
Can username/password syntax obscure the host?Does it normalize alternative IPv4 syntax?
How does it handle IPv6 brackets?
How does it handle zone identifiers?
What does it reject?
Can username/password syntax obscure the host?Layer 2 โ DNS
Questions:
Are A and AAAA both checked?
What happens if they disagree?
Are all resolved addresses checked?
Can resolution change between validation and connection?
Is the validated IP pinned to the request?Are A and AAAA both checked?
What happens if they disagree?
Are all resolved addresses checked?
Can resolution change between validation and connection?
Is the validated IP pinned to the request?Layer 3 โ Address classifier
Questions:
CIDR or string matching?
Who decides the address family?
Are mapped/translated forms normalized?
What happens to malformed input?
Are special-purpose ranges modeled explicitly?CIDR or string matching?
Who decides the address family?
Are mapped/translated forms normalized?
What happens to malformed input?
Are special-purpose ranges modeled explicitly?Layer 4 โ HTTP client
Questions:
Can redirects escape the validated destination?
Does the client resolve DNS again?
Does it use a proxy?
Which IP does it actually connect to?Can redirects escape the validated destination?
Does the client resolve DNS again?
Does it use a proxy?
Which IP does it actually connect to?This is much more powerful than memorizing dozens of SSRF bypass strings.
The payloads change.
The semantic boundaries remain.
SSRF Is a Reachability Problem
This leads to the central idea.
SSRF filters frequently ask:
Does this URL contain a private IP?Does this URL contain a private IP?But the real security property is closer to:
Can this attacker-controlled request cause the server to reach a destination the attacker should not be able to reach?
That includes more than RFC1918.
Depending on the environment, interesting destinations may include:
loopback
link-local
cloud metadata
service discovery
internal DNS
multicast
container bridges
overlay networks
Kubernetes services
Tailscale networks
internal control planes
IPv4 embedded inside IPv6loopback
link-local
cloud metadata
service discovery
internal DNS
multicast
container bridges
overlay networks
Kubernetes services
Tailscale networks
internal control planes
IPv4 embedded inside IPv6The network security boundary depends on deployment topology.
There is no universal regex for:
things my server should not talk tothings my server should not talk toClassification Bypass Is Not Automatically Exploitation
This distinction is important.
Some of the IPv6 transition mechanisms examined during the Formbricks work โ such as NAT64 or 6to4 โ require corresponding network infrastructure before a classification mistake becomes an end-to-end SSRF path.
The merged PR explicitly calls this out.
The classifier could be shown to accept representations that policy intended to reject.
That proves:
classification bypassclassification bypassIt does not automatically prove:
every deployment can exploit this representationevery deployment can exploit this representationFor example:
NAT64 representation acceptedNAT64 representation accepteddoes not mean:
target deployment definitely has a NAT64 gatewaytarget deployment definitely has a NAT64 gatewayGood vulnerability research should preserve that distinction.
The strongest finding is not the one with the biggest claim.
It is the one whose claim boundary exactly matches the evidence.
Why This Still Matters Without a Universal Exploit Chain
A security boundary should implement its declared policy correctly.
If the product says:
internal and non-routable destinations are deniedinternal and non-routable destinations are deniedthen the classifier should actually model those address classes.
Otherwise security depends on environmental accidents:
this deployment happens not to route it
this cloud happens not to support it
this transition mechanism happens not to existthis deployment happens not to route it
this cloud happens not to support it
this transition mechanism happens not to existThat is fragile.
Defense-in-depth exists precisely because deployment assumptions change.
Today's unreachable range may become tomorrow's routed internal path after a networking change.
Special-Purpose Address Space Is Not Just "Private IPs"
Another important lesson is vocabulary.
Developers often reduce the problem to:
RFC1918
+
localhostRFC1918
+
localhostBut IP space contains many categories:
private-use
loopback
link-local
shared address space
multicast
documentation
benchmarking
protocol assignments
translation prefixes
deprecated mechanisms
discard-only rangesprivate-use
loopback
link-local
shared address space
multicast
documentation
benchmarking
protocol assignments
translation prefixes
deprecated mechanisms
discard-only rangesWhether each should be rejected depends on the application's policy.
But they should be considered intentionally.
Accidental omission is different from deliberate allowance.
A good security control can explain:
why this range is blockedwhy this range is blockedand just as importantly:
why this range is allowedwhy this range is allowedPositive Security Models Are Easier to Reason About
The deeper lesson is not necessarily:
block more IPsblock more IPsIn many applications, a better architecture may be:
allow only HTTPS
resolve once
classify canonically
reject internal/special destinations
pin validated address
disable redirects
control proxy behaviorallow only HTTPS
resolve once
classify canonically
reject internal/special destinations
pin validated address
disable redirects
control proxy behaviorOr, for particularly sensitive integrations:
allowlist known webhook destinationsallowlist known webhook destinationsThe narrower the intended behavior, the easier the security model becomes.
A denylist must understand the universe of dangerous destinations.
An allowlist only needs to understand the intended ones.
That is not always practical, but it is worth recognizing the asymmetry.
This Pattern Goes Beyond SSRF
The general mistake is:
Representing semantic security properties with textual heuristics.
We see the same thing elsewhere.
Filesystems
".."".."is not the same concept as:
path escapes allowed rootpath escapes allowed rootDomains
string.endsWith("example.com")string.endsWith("example.com")is not automatically the same as:
host belongs to example.comhost belongs to example.comURLs
startsWith("https://trusted.com")startsWith("https://trusted.com")is not:
origin == trusted originorigin == trusted origincontains("@company.com")contains("@company.com")is not:
mailbox belongs to company.commailbox belongs to company.comAuthorization
resource ID existsresource ID existsis not:
current principal may access resourcecurrent principal may access resourceOur earlier Nezha research was essentially the authorization version of the same mistake.
The security model was richer than the representation being checked.
A Practical SSRF Classifier Checklist
When auditing URL-based server-side fetch functionality, I now want answers to these questions:
1. Which parser defines the URL semantics?
2. Is host normalization performed before policy?
3. Are IP ranges represented as actual CIDRs?
4. Are IPv4 and IPv6 both supported?
5. Are IPv4-in-IPv6 representations considered?
6. Does the classifier derive address family from the address itself?
7. What happens to malformed or unknown input?
8. Are all DNS answers validated?
9. Can DNS change after validation?
10. Does the request connect to the exact validated address?
11. Are redirects followed?
12. Can a proxy reinterpret the destination?
13. Are cloud/platform metadata endpoints handled?
14. Are boundary addresses tested on both sides of every prefix?
15. Can the security rule explain why each allowed special-purpose range is safe?1. Which parser defines the URL semantics?
2. Is host normalization performed before policy?
3. Are IP ranges represented as actual CIDRs?
4. Are IPv4 and IPv6 both supported?
5. Are IPv4-in-IPv6 representations considered?
6. Does the classifier derive address family from the address itself?
7. What happens to malformed or unknown input?
8. Are all DNS answers validated?
9. Can DNS change after validation?
10. Does the request connect to the exact validated address?
11. Are redirects followed?
12. Can a proxy reinterpret the destination?
13. Are cloud/platform metadata endpoints handled?
14. Are boundary addresses tested on both sides of every prefix?
15. Can the security rule explain why each allowed special-purpose range is safe?This is a much stronger review framework than:
try 127.0.0.1
try 169.254.169.254
try ::1try 127.0.0.1
try 169.254.169.254
try ::1The Most Important Question
When I see code like:
PRIVATE_IP_PATTERNS = [...]PRIVATE_IP_PATTERNS = [...]the first question I now ask is not:
Which regex is missing?
It is:
Why are we using regex to model networks at all?
Sometimes the answer is legitimate.
Often it is historical.
A small initial check grows over time:
localhost
โ
RFC1918
โ
IPv6
โ
cloud metadata
โ
new bypass
โ
another regex
โ
another exceptionlocalhost
โ
RFC1918
โ
IPv6
โ
cloud metadata
โ
new bypass
โ
another regex
โ
another exceptionEventually the implementation becomes a hand-built networking library.
At that point, the security problem is architectural.
Conclusion
The Formbricks webhook validation work reinforced a simple principle for me:
Security controls should operate on the semantic object they are trying to secure.
If the policy is about networks, use network primitives.
If the policy is about origins, use origin semantics.
If the policy is about filesystem containment, resolve filesystem paths.
If the policy is about authorization, preserve principals and relationships.
Strings are representations.
They are not the security property itself.
For SSRF research, the reusable methodology is:
Trace every transformation from attacker-controlled URL to final socket destination, and look for places where the validator's interpretation can diverge from the network stack's interpretation.
The interesting bypass is often not another weird IP spelling.
It is the gap between:
what the filter thinks the destination iswhat the filter thinks the destination isand:
where the request can actually gowhere the request can actually goThat gap is where SSRF defenses break.
Disclosure / References
This article is based on my earlier Formbricks webhook-security research tracked as ENG-1326 and the subsequent public remediation work.
The merged Formbricks PR #8946 โ "fix: match webhook URL denylist ranges as CIDRs (ENG-2554)" explicitly credits Berkan SAL (@Uhudsavasindankacanokcu2) for the ranges reported in ENG-1326 and states that they were confirmed to still reproduce against the previous classifier.
The remediation replaced IPv4 regexes and IPv6 string-prefix checks with Node.js net.BlockList CIDR matching, corrected additional boundary and normalization issues, derived IP family from the parsed address, and changed unparseable inputs to fail closed.
Some transition-address cases discussed during the remediation depend on network-specific infrastructure such as NAT64 or 6to4. Those results demonstrate classifier-policy gaps; they should not be interpreted as proof that every affected representation provides an exploitable end-to-end SSRF path in every deployment.