On July 26, 2026, I reported an SSRF issue in V 0.5.2. The issue was in the URL handling shared by net.urllib and net.http.

A URL such as this one could pass a domain allowlist as google.com:

http://127.0.0.1\@google.com/

But when the same URL was fetched with net.http.get(), the request reached 127.0.0.1 instead. That makes it possible to bypass an allowlist and send a server-side request to an internal service. 1

Reproducing the behavior

This is the reproducer that I reported in issue #27945. The code below uses the same V APIs and the same URL, allowlist, and request flow:

import net.urllib
import net.http

fn main( ) {
    target := 'http://127.0.0.1\\\\@google.com/'
    whitelist := ['google.com', 'github.com']
    
    url := urllib.parse(target ) or { return }
    println("Parsed Host: ${url.host}") 
    
    if url.host !in whitelist {
        println("Access Denied")
        return
    }
    println("Check Passed")
    
    println("Fetching URL" )
    http.get(target ) or { return }
    println("Connected to 127.0.0.1 instead of google.com")
}

The important part is that the program parses target once for the allowlist check, then passes the original raw string to http.get() for the request. The value being checked and the value being used by the HTTP client do not end up representing the same destination.

What parse() does with the URL

In the affected V source, the public parser is only a wrapper:

pub fn parse(rawurl string) !URL {
    return parse_url(rawurl, false)
}

The first part of parse_url() separates the scheme from the rest of the URL. For the payload, the parser gets these parts:

scheme = http
rest   = //127.0.0.1\@google.com/

The parser then handles the authority when the remaining string starts with //:

if ((url.scheme != '' || !via_request) && !rest.starts_with('///'))
    && rest.starts_with('//') && rest.len > 2 {
    authority, r := split(rest[2..], `/`, false)
    rest = r
    a := parse_authority(authority)!
    url.user = a.user
    url.host = a.host
}

The split() call separates the authority from the path at the first /. The values are now:

authority = 127.0.0.1\@google.com
rest       = /

The authority is passed to parse_authority().

The authority split

The relevant code in parse_authority() searches for the last @:

fn parse_authority(authority string) !ParseAuthorityRes {
    i := authority.last_index_u8(`@`)

    if i < 0 {
        return ParseAuthorityRes{
            host: parse_host(authority)!
            user: user('')
        }
    }

    raw_user, raw_host := authority[..i], authority[i + 1..]

For the payload, that produces:

raw_user = 127.0.0.1\
raw_host = google.com

This is the key split. The backslash is before the @, so it becomes part of raw_user. The host parser never receives 127.0.0.1\; it receives google.com.

The parser checks the userinfo before parsing the host:

if !valid_userinfo(raw_user) {
    return error(error_msg('parse_authority: invalid userinfo', ''))
}

host := parse_host(raw_host)!

Why the backslash is accepted

In the vulnerable version, valid_userinfo() explicitly allowed a backslash:

pub fn valid_userinfo(s string) bool {
    for r in s {
        if r.is_alnum() {
            continue
        }

        match r {
            `-`, `.`, `_`, `:`, `~`, `!`, `$`, `&`, `\\`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `%`,
            `@` {
                continue
            }
            else {
                return false
            }
        }
    }
    return true
}

The string 127.0.0.1\ therefore passes valid_userinfo(). It is not rejected as an invalid authority character.

parse_authority() then decodes the accepted userinfo and returns it together with the host:

name, pwd := split(raw_user, `:`, true)
auth := if pwd != '' {
    user_password(unescape(name, .encode_user_password)!, unescape(pwd, .encode_user_password)!)
} else {
    user(unescape(name, .encode_user_password)!)
}

return ParseAuthorityRes{
    user: auth
    host: host
}

The URL object returned by urllib.parse() is effectively:

url.user.username = 127.0.0.1\
url.host          = google.com
url.path          = /

What parse_host() receives

parse_host() receives only raw_host, which is google.com. This is the actual function from the affected source:

fn parse_host(host string) !string {
    if host.len > 0 && host[0] == `[` {
        // parse an IP-Literal in RFC 3986 and RFC 6874.
        i := host.last_index_u8(`]`)
        if i == -1 {
            return error(error_msg("parse_host: missing ']' in host", ''))
        }
        mut colon_port := host[i + 1..]
        if !valid_optional_port(colon_port) {
            return error(error_msg('parse_host: invalid port ${colon_port} after host ', ''))
        }
        if zone := host[..i].index('%25') {
            host1 := unescape(host[..zone], .encode_host)!
            host2 := unescape(host[zone..i], .encode_zone)!
            host3 := unescape(host[i..], .encode_host)!
            return host1 + host2 + host3
        }
    } else {
        i := host.last_index_u8(`:`)
        if i != -1 {
            colon_port := host[i..]
            if !valid_optional_port(colon_port) {
                return error(error_msg('parse_host: invalid port ${colon_port} after host ', ''))
            }
        }
    }
    h := unescape(host, .encode_host)!
    return h
}

There is no port in google.com, and the host contains no character that the old parser rejects. unescape() returns google.com, so the host stored in the URL remains:

google.com

The host allowlist therefore sees google.com and accepts the URL:

if url.host !in whitelist {
    println('Access Denied')
    return
}

The check passes because the parser returned the host that the application trusts.

The error-handling problem in unescape()

The same old parser also had a separate error-propagation problem. In host mode, unescape() constructed an invalid-host error without returning it:

if (mode == .encode_host || mode == .encode_zone)
    && s[i] < 0x80
    && should_escape(s[i], mode) {
    error(error_msg('unescape: invalid character in host name', s[i..i + 1]))
}

That should have stopped parsing, but the error was discarded. This is related to the overall parser weakness and was fixed in the same commit.

For this exact payload, the direct allowlist bypass happens one step earlier: the backslash is placed before @, so parse_authority() puts it in raw_user, and valid_userinfo() accepts it. parse_host() only sees google.com. The discarded host error is still important because it shows that the old parser was not consistently rejecting invalid authority input, but it is not the specific branch that separates this payload into userinfo and host.

How the request reaches the network

The allowlist check calls urllib.parse(target), but the request uses the original string again:

pub fn get(url string) !Response {
    return fetch(method: .get, url: url)
}

In the affected HTTP source, the request path is:

http.get(target)
    -> fetch(config)
    -> prepare(config)
    -> build_url_from_fetch(config)
    -> Request.do()
    -> method_and_url_to_response()
    -> http_do()
    -> net.dial_tcp()

prepare() builds the request URL from the raw input. build_url_from_fetch() parses the input and serializes the resulting URL:

fn build_url_from_fetch(config FetchConfig) !string {
    mut url := urllib.parse(config.url)!

    if config.params.len == 0 {
        return url.str()
    }

    mut pieces := []string{cap: config.params.len}
    for key, val in config.params {
        pieces << '${key}=${val}'
    }

    mut query := pieces.join('&')
    if url.raw_query.len > 1 {
        query = url.raw_query + '&' + query
    }

    url.raw_query = query
    return url.str()
}

The HTTP request object later parses its URL again:

pub fn (req &Request) do() !Response {
    mut rurl := urllib.parse(req.url) or {
        return error('http.Request.do: invalid url ${req.url}')
    }

    qresp := req.method_and_url_to_response(method, rurl, data, header)!
    return qresp
}

The request code takes the hostname from the parsed URL, chooses the port, and sends the host to the HTTP transport. These are the actual relevant lines from method_and_url_to_response() in the affected source:

host_name := url.hostname()
scheme := url.scheme
p := url.escaped_path().trim_left('/')
path := if url.query().len > 0 { '/${p}?${url.query().encode()}' } else { '/${p}' }
mut nport := url.port().int()
if nport == 0 {
    if scheme == 'http' {
        nport = 80
    }
    if scheme == 'https' {
        nport = 443
    }
}

For a direct HTTP request, the same function calls http_do() with the hostname and selected port:

} else if scheme == 'http' && req.proxy == unsafe { nil } {
    for i in 0 .. req.max_retries {
        res := req.http_do('${host_name}:${nport}', method, path, data, header) or {
            if i == req.max_retries - 1 || is_no_need_retry_error(err.code()) {
                return err
            }
            continue
        }
        return res
    }
}

Inside http_do(), the host is passed to the TCP dialer. These are the actual connection lines:

host_name, port := net.split_address(host)!
s := req.build_request_headers_with(method, host_name, port, path, data, header)
mut client := net.dial_tcp(host)!
client.write(s.bytes())!

The issue report records the observed result of this request path: net.http.get() normalizes the backslash and connects to 127.0.0.1. The source excerpts above show the actual request functions and the point where the chosen hostname reaches net.dial_tcp(); the issue is the source for the specific normalization behavior. I am not adding a made-up replace() call because there is no such line in the source excerpts.

The final mismatch is:

Host returned to the allowlist: google.com
Host reached by the request:    127.0.0.1

That is the SSRF bypass. An attacker does not need the application to allow 127.0.0.1. They only need the application to validate a URL using the parser’s result and then pass the original URL to a component that interprets it differently.

The patch

The fix was merged in commit 85859f0 through pull request #27947. It changed the URL parser rather than adding a special check for this one payload.

The old host-character rules allowed a raw backslash. The patch removes it and restores the apostrophe as the RFC-valid sub-delimiter:

// old
`!`, `$`, `&`, `\\`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `:`, `[`, `]`, `<`, `>`

// fixed
`!`, `$`, `&`, `'`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `:`, `[`, `]`, `<`, `>`

The same change was made to valid_userinfo(), so the backslash in 127.0.0.1\ no longer passes the userinfo validation.

The discarded error in unescape() was changed to a returned error:

return error(error_msg('unescape: invalid character in host name', s[i..i + 1]))

The parser now rejects the malformed authority instead of continuing with a value that another component may interpret differently.

The fix also added regression tests for these URLs:

http://127.0.0.1\@google.com/
http://user@google.com\path
http://google.com\path

The tests require the parser to reject them. A separate test confirms that apostrophes remain valid in authority components. 3

The issue was closed as completed on July 26, 2026. CVE-2026-67201 was assigned later, and the CVE record lists V through 0.5.2 as affected and commit 85859f0 as the fix. 1

TL;DR

V 0.5.2 accepted a crafted URL containing a backslash before the @ separator: http://127.0.0.1\@google.com/. In net.urllib, parse_authority() treated 127.0.0.1\ as userinfo and google.com as the host. A program checking url.host against an allowlist could therefore approve the URL as an allowed request to google.com.

The original URL was then passed to net.http.get(). The HTTP request path interpreted the backslash differently and connected to 127.0.0.1, allowing the allowlist to be bypassed and the server to reach an internal service. The fix removed backslashes from the valid host and userinfo character sets, corrected error propagation in unescape(), and added regression tests for the crafted authority cases.

References

  1. NVD: CVE-2026-67201
  2. V issue #27945: Security: SSRF in net.urllib due to backslash parsing
  3. V pull request #27947: net.urllib: reject backslashes in URL authorities
  4. V commit 85859f0: net.urllib: reject backslashes in URL authorities
  5. V pre-fix net.urllib source
  6. V pre-fix net.http source
  7. V pre-fix HTTP request source