DEV Community

Cover image for Your SSRF Filter Probably Doesn't Handle Unicode IPs
Schiff Heimlich
Schiff Heimlich

Posted on

Your SSRF Filter Probably Doesn't Handle Unicode IPs

You know that regex you wrote to validate IP addresses? The one that blocks requests to internal networks?

It probably doesn't handle this:

⑥⑨.㉚.⑳⑥.⑬⓪
Enter fullscreen mode Exit fullscreen mode

That resolves to 69.20.206.130. Or this:

①②⑦.⓪.⓪.①
Enter fullscreen mode Exit fullscreen mode

That's 127.0.0.1.

What's happening

glibc's getaddrinfo() — the function almost every Linux program uses to resolve hostnames — accepts IPv4 addresses written with circled digits, superscripts, subscripts, and math-alphabet characters. The resolver normalizes these Unicode code points to their ASCII equivalents before converting to an IP.

So when your application does:

import socket
socket.getaddrinfo('①②⑦.⓪.⓪.①', 80)
Enter fullscreen mode Exit fullscreen mode

It returns the same result as 127.0.0.1.

Why this matters

Most SSRF protections work like this:

  1. Parse the URL
  2. Extract the host
  3. Check if the host matches a blocklist (e.g., 127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16)
  4. If it passes, make the request

The problem: step 3 uses a regex or IP parser that only understands ASCII digits. Step 2 never converts the Unicode digits to ASCII. So ①②⑦.⓪.⓪.① passes your blocklist check, then getaddrinfo() resolves it to 127.0.0.1, and your request goes to localhost.

A real example

Say you have a webhook handler that fetches user-provided URLs:

import requests
import re

def is_safe_ip(host):
    # Block private ranges
    if re.match(r'^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)', host):
        return False
    return True

@app.route('/webhook', methods=['POST'])
def webhook():
    url = request.json['url']
    host = urlparse(url).hostname

    if not is_safe_ip(host):
        return "Blocked", 403

    response = requests.get(url)
    return response.text
Enter fullscreen mode Exit fullscreen mode

The regex checks for 127. at the start. But ①②⑦.⓪.⓪.① doesn't match. So the request proceeds, and requests.get() (which uses getaddrinfo() under the hood) fetches from 127.0.0.1.

The fix

Resolve the hostname to an IP before checking the blocklist:

import socket
from ipaddress import ip_address, ip_network

def is_safe_ip(host):
    try:
        # Resolve to IP first
        addr_info = socket.getaddrinfo(host, None)
        ip_str = addr_info[0][4][0]
        ip = ip_address(ip_str)

        # Check private ranges
        private_ranges = [
            ip_network('127.0.0.0/8'),
            ip_network('10.0.0.0/8'),
            ip_network('172.16.0.0/12'),
            ip_network('192.168.0.0/16'),
            ip_network('169.254.0.0/16'),
            ip_network('::1/128'),
            ip_network('fc00::/7'),
        ]

        for network in private_ranges:
            if ip in network:
                return False
        return True
    except (socket.gaierror, ValueError):
        return False
Enter fullscreen mode Exit fullscreen mode

Now the Unicode digits get normalized by getaddrinfo(), you extract the actual IP, and your blocklist check works correctly.

A few notes

  • This isn't a glibc bug — it's documented behavior. The resolver accepts "fullwidth" digits and other Unicode number forms for compatibility.
  • The issue affects any language that uses the system resolver (Python, Ruby, Node.js on Linux, Go with CGO enabled, etc.).
  • If you're writing a validator in JavaScript (which doesn't use glibc), you might not be vulnerable. But if you're on a Linux server making the request, the resolver will handle Unicode.

The takeaway

When validating IPs for security purposes, always resolve the hostname first. Don't trust the raw input. Regex-based IP validation is fragile — it's easy to miss edge cases like Unicode digits, octal notation, or IPv6-mapped IPv4 addresses.

The ipaddress module (or your language's equivalent) handles the parsing correctly. Use it.


This one caught me off guard. I'd written the regex-based validator years ago and never questioned it. Took a security audit to realize it was bypassable. The fix is straightforward, but you have to know to look for it.

Top comments (0)