# Can URL userinfo hide your API destination?

An agent that accepts an arbitrary URL can be pointed somewhere its operator did not intend. URL userinfo makes that mistake easier because it puts a familiar hostname before an `@` sign while the actual destination sits after it. If your approval screen, allowlist, or audit view treats the whole string as a host-shaped label, a request can look approved and still leave for another server.

Treat userinfo as disallowed input for outbound API actions unless you have a narrow, documented compatibility reason to support it. Parse the URL once at the boundary, reject a nonempty userinfo field before credentials enter the request, and make every later decision from parsed fields. This is not an exotic URL trick. It is ordinary syntax meeting a review surface that asks people to read too much punctuation too quickly.

## The hostname is after the final at sign

In an absolute HTTP URL, the authority sits between `//` and the next `/`, `?`, or `#`. RFC 3986 describes that authority as optional userinfo, then `@`, then host, then optional port. The host is therefore not whatever text appears first after `//`.

Consider this request:

```text
https://api.example.com@collector.invalid/v1/charges
```

A person scanning the beginning may register `api.example.com` and stop there. A conforming URL parser separates it this way:

```text
scheme:   https
userinfo: api.example.com
host:     collector.invalid
port:     443
path:     /v1/charges
```

The TCP connection and TLS hostname verification use `collector.invalid`. The string before `@` does not identify the remote server. It is userinfo, a legacy part of the URL grammar once used for names and passwords.

The same issue appears in a more familiar-looking form:

```text
https://billing.example.com:wrong@evil.invalid/invoices
```

Everything before the `@` is still userinfo, including the colon and the text after it. The remote host remains `evil.invalid`. A reviewer cannot safely infer destination from the first hostname-like substring in a raw URL.

Do not try to solve this by teaching reviewers to notice the character. People make this mistake during incident review, at the end of a long day, and when an agent produces many action requests. A control that depends on flawless visual parsing is a weak control.

The URL Standard used by browsers and many runtimes has the same practical result for special schemes such as HTTPS: parsed username and password fields are separate from the hostname. Exact parser behavior can differ around malformed input and escaping, which is another reason to parse with the runtime that will send the request. Do not validate with one library and execute with another that accepts a different set of strings.

A useful rule is simple: only a parsed hostname may decide where a request is allowed to go. Raw URL text is evidence, not authority.

## Userinfo creates a review problem before it creates a network problem

The network client usually knows where it is going. The failure happens earlier, when a person or a control approves the wrong representation of that destination.

A typical agent flow has several places where raw text can leak through: the tool call argument, an approval card, a session journal, an error message, and a notification. If one of those views says `Calling api.example.com` because it extracts text before `@`, it tells an operator something false. If another view logs the full string but truncates it after a fixed width, the actual host can disappear completely.

This matters even when the agent has no credential for the target. An outbound request can carry user data, a signed request body, a bearer token chosen by a broad rule, or simply reach an internal address that should never receive agent traffic. The credential story gets attention because it is concrete. Destination integrity needs the same discipline.

The distinction people often blur is between URL display safety and URL transport safety. Escaping `@` in an HTML view may make a page less confusing, but it does not determine where an HTTP client connects. Conversely, a parser may make the network call correctly while a badly designed approval view still encourages a person to approve the wrong host. You need both a correct transport decision and an honest display.

Do not replace `@` with an innocuous character in the stored request and continue. That hides the input that caused the denial and makes later investigation harder. Preserve the original string as raw input, mark the request as rejected, and record the parsed reason without writing embedded credentials into a readable journal.

The display should lead with a separate destination field, such as `Host: collector.invalid`, and place the original URL below it. That ordering turns a punctuation puzzle into a direct statement. It also gives a reviewer a stable field to compare with the requested credential or the intended integration.

## Rejecting userinfo is safer than repairing it

For an agent action gateway, the clean default is to reject every outbound HTTP URL whose parsed username or password field is nonempty. Most API integrations already send credentials in request headers or use a credential injector. Supporting URL userinfo adds attack surface without solving an ordinary API need.

The validation order matters. Parse the original string first. Reject malformed URLs, unsupported schemes, and userinfo before host allowlisting, approval prompts, redirects, DNS resolution, or credential selection. That sequence avoids displaying a request as eligible when you will later discard part of it.

This pseudocode expresses the rule:

```text
u = parse_absolute_url(raw_url)

if u.scheme not in {"https", "http"}:
    deny("unsupported scheme")

if u.username != "" or u.password != "":
    deny("URL userinfo is not accepted")

host = normalize_hostname(u.hostname)
if host == "":
    deny("missing hostname")

if not destination_is_allowed(u.scheme, host, u.port):
    deny("destination is not allowed")

send(u)
```

The parser must return structured fields. Splitting on `@`, stripping a prefix, or searching for a hostname substring all fail on ordinary variations. An authority can contain more than one `@` in raw input. A parser decides which delimiter is syntactic and whether earlier characters belong to userinfo. It also handles bracketed IPv6 literals, explicit ports, percent encoding, and empty components more consistently than handwritten string logic.

Rejecting userinfo gives the caller a clear correction: use `https://api.example.com/path`, then supply HTTP authentication through the designated credential path. It does not leave the caller guessing whether you silently changed `https://name@host` into `https://host`.

There is one compatibility case worth acknowledging. Some old URLs embed Basic authentication as `https://name:secret@host/path`. If a migration must handle those URLs, do it in a one-time import path that extracts the credentials into protected storage, confirms the parsed host, and deletes the source string from the import record where policy permits. Do not allow the runtime action API to keep accepting them forever. Temporary compatibility code has a habit of becoming permanent attack surface.

## Host allowlists need parsed labels, not friendly-looking strings

A destination allowlist should compare normalized parsed hostnames, not run a substring search over the raw URL. The rule `raw_url.includes("api.example.com")` accepts both `https://api.example.com@evil.invalid` and `https://api.example.com.evil.invalid`. Neither is a request to `api.example.com`.

Exact host matching is the least surprising rule. If an integration needs only `api.example.com`, allow that name and reject every other hostname. If it genuinely needs subdomains, compare DNS labels: permit `example.com` and names ending in `.example.com`, but reject `badexample.com` and `example.com.evil.invalid`.

A clear implementation shape looks like this:

```text
function allowedHost(host, root) {
  const h = host.toLowerCase().replace(/\.$/, "")
  const r = root.toLowerCase().replace(/\.$/, "")
  return h === r || h.endsWith("." + r)
}
```

That code assumes the URL parser has already supplied a hostname and the caller has already rejected userinfo. It should not receive a full URL. Keeping those responsibilities separate prevents a later caller from passing an authority string that contains a port, a username, or an `@` sign.

Internationalized domains need a decision too. Browsers commonly serialize hostnames in ASCII using IDNA processing, while a user may see Unicode text. Use the same canonical form your request client uses for comparison, and show both the canonical host and a readable form when they differ. Do not claim two strings identify the same domain because they look similar in a proportional font.

IP addresses deserve their own rule. A hostname allowlist does not automatically make an IP literal safe, and DNS resolution after approval can change the address a hostname reaches. If your threat model includes access to local services, decide explicitly whether private, loopback, link-local, and IPv6 local addresses are allowed. Rejecting userinfo is necessary, but it does not solve server-side request forgery by itself.

Ports also carry meaning. `https://api.example.com:8443` may be a valid partner endpoint or an unexpected administration service. Record the effective port and include it in approval decisions where the integration restricts one. A host label alone does not describe the full network destination.

## Redirects must pass through the same gate

A permitted initial URL does not make every redirect target permitted. HTTP redirects are new destination instructions supplied by the remote server, and an agent gateway should parse and authorize each one before following it.

Suppose an agent requests `https://api.example.com/export`. That host returns a 302 response with this location:

```text
https://api.example.com@receiver.invalid/download?id=42
```

A client that follows redirects automatically will next connect to `receiver.invalid`. If the gateway approved only the first URL, its allowlist and approval screen no longer describe the network action that occurred.

Handle redirects as a loop with a limit chosen for your client. For every location value, resolve a relative reference against the current approved URL, parse the resulting absolute URL, apply the same scheme, userinfo, host, port, and address rules, then decide whether to proceed. Record both the source response and the parsed redirect destination.

Do not forward credentials across hosts by default. HTTP client libraries differ on whether they retain an `Authorization` header after a cross-host redirect, and custom headers can behave differently again. The safest gateway behavior associates a credential with a specific approved destination and constructs a new outbound request only after the redirect destination passes authorization. A redirect from one vendor-owned host to another may be expected, but it should be an explicit rule rather than an accident of library defaults.

Method handling needs attention as well. A 303 response commonly changes a later request to GET, while 307 and 308 preserve the method and body. If an agent posts a sensitive body, a preserved-method redirect can send it elsewhere. Record the method used on every hop and show the final destination in the action result.

A client can also be configured not to follow redirects. That is a sensible choice for narrow API tools. Return the redirect response to the agent and require it to ask for the next destination explicitly. This produces another approval event, but it gives the operator a clean point to assess a host change. For broad HTTP support, automatic redirects are acceptable only when each hop crosses the same gate.

## Credentials must be selected after destination validation

The dangerous ordering is easy to describe: pick a credential because the raw URL contains a familiar service name, then parse the URL, then send the request. An `@` trick can turn the familiar text into userinfo while the selected secret travels to an attacker-controlled host.

The safer ordering is equally plain. First parse and validate the URL. Next authorize its scheme, host, port, and any redirect state. Only then find a credential bound to that approved destination and inject it into the outgoing request. Keep that secret out of tool arguments, agent memory, and return values.

This also solves a less dramatic but common configuration error. A credential associated with `api.example.com` should not automatically go to `uploads.example.com`, even if both names sit under the same parent domain. Different hosts often have different ownership, TLS termination, logging, or permission scopes. Start with exact host binding. Broaden the match only when the integration documents why it needs more.

Header injection is preferable to URL userinfo because it separates destination from authentication. A request record can say that an authorization header was injected without storing its value. The agent receives the response needed to continue its work, not a reusable secret.

Sallyport follows that separation by keeping API and SSH credentials in its encrypted vault and executing the outbound action without exposing those credentials to the agent. For any gateway with that model, rejecting userinfo before credential lookup closes the gap between an operator's intended destination and the host that receives the request.

Do not trust a request header supplied by the agent to identify its destination either. The `Host` header, HTTP/2 authority, URL host, proxy configuration, and TLS server name can interact in client-specific ways. A gateway should own the connection settings and derive them from the validated parsed URL. If you allow custom headers, treat them as request content, not as permission to rewrite routing.

## Approval cards should show the parsed destination first

An approval card should answer three concrete questions without asking its reader to reconstruct a URL: which process asked, what operation will run, and which host will receive it. Put the parsed hostname and port in a dedicated destination line. Put the HTTP method and path nearby. Show the original URL as supporting evidence, not as the only destination signal.

For the malformed-looking request used earlier, a useful card would say:

```text
Process: signed agent process
Action:  POST /v1/charges
Host:    collector.invalid:443
Result:  blocked because URL userinfo is present
Input:   https://api.example.com@collector.invalid/v1/charges
```

It should not display `api.example.com` as a badge derived from the left side of the authority. Nor should it say only `External HTTP request`, which gives a person no meaningful basis for a decision.

Per-session approval and per-call approval solve different human problems. A session approval says that a particular running process may use a gateway for its lifetime. A per-call approval says that an especially sensitive credential or action needs a fresh human decision. Neither replaces basic URL validation. A process you trust can still be manipulated by an untrusted issue comment, package metadata field, or generated configuration file.

Sallyport's decision ladder keeps a locked vault as an absolute stop, then uses session authorization and optional per-secret confirmation. That structure works best when malformed destinations fail before they reach an approval card, because an operator should not have to decide whether a piece of URL punctuation changed the endpoint.

Be specific in denial messages. `Userinfo is not allowed in outbound URLs` tells an agent developer what to fix. `Invalid request` causes retries, ad hoc escaping, and pressure to weaken validation. Avoid echoing a password if the parser extracted one. The message can name the prohibited component without reproducing its contents.

## Tests should use deceptive inputs, not only happy URLs

A validation test suite needs examples designed to fool human readers and simplistic string checks. Passing `https://api.example.com/v1` proves almost nothing about the boundary where an agent can submit arbitrary text.

Start with cases like these and assert the parsed host, decision, and reason:

```text
ALLOW  https://api.example.com/v1                 host=api.example.com
DENY   https://api.example.com@evil.invalid/v1    reason=userinfo
DENY   https://name:secret@api.example.com/v1     reason=userinfo
DENY   https://api.example.com.evil.invalid/v1    reason=host
DENY   https://api.example.com:444/v1             reason=port
DENY   https://[::1]/v1                           reason=address
```

Add a redirect fixture. Have an allowed test server issue a redirect whose location contains userinfo and assert that the client records a blocked second hop without sending a request to the destination server. This catches the frequent mistake where initial URL validation lives in one code path and redirect handling lives inside a library callback.

Test percent encoding deliberately, but do not assume every encoded `@` is equivalent. In many parsers, `%40` in a path remains path data while an actual `@` in the authority acts as a delimiter. Feed the exact raw string into the parser you use in production and assert its fields. The important invariant is not a homemade decoding rule. It is that a nonempty parsed userinfo field cannot reach the sender.

Also test log and approval rendering. A security control can make the correct denial and still create a bad operational record if the display truncates the actual host or exposes parsed password text. Snapshot tests for destination lines are worth having because visual regressions often arrive through harmless-looking design changes.

Finally, test the audit chain and revoke path around a denied call. A denial should be visible enough for investigation, yet it should never include injected secrets. The useful record is the raw request under appropriate access controls, parsed scheme and host, decision reason, calling process identity, and the fact that no outbound action occurred.

## URL syntax is not a policy language

Some teams respond to URL edge cases by adding a growing pile of exceptions: permit a username for this vendor, accept a special port for that environment, trust a redirect only when a header looks right, and patch a string matcher for each incident. That approach feels flexible because it avoids saying no to a strange request. It also creates rules that nobody can reliably review.

Keep the rule small. Outbound requests have a parsed destination. Userinfo is rejected. The allowed scheme, host, port, and address class are explicit. Redirects re-enter the same checks. Credentials are selected only after the destination passes. Each decision produces a record that states the parsed host plainly.

That rule will reject a few old URLs that a browser might accept. Good. An autonomous agent does not need every historical affordance of a browser address bar. It needs a narrow interface that makes the action, destination, and credential relationship hard to confuse.

If you need to change that interface, make the exception visible as a named capability with tests and an expiration decision. Do not bury it in URL cleanup code. The first hostile input will find the difference between text that looks like a host and the host your client actually contacts.
