How Unicode hostname prompts can approve the wrong destination
Unicode hostname prompts need one canonical destination before an HTTP gateway adds credentials. Test punycode, scripts, dots, invisibles, and redirects.

An approval card for an agent's HTTP request is a security boundary, not a courtesy notification. If the card says one destination and the HTTP stack sends credentials to another, the user did not make an informed decision. The gateway made a decision on their behalf, with a nicer typeface.
Unicode makes that mismatch easy to create because a hostname has several forms that look related but do different jobs. A user may see Unicode. DNS receives ASCII-compatible labels. A URL parser may normalize separators, percent escapes, case, IPv4 notation, or an empty final label. A redirect handler may parse the next location with another code path. If any layer approves one representation and another layer connects with a different representation, credentials can leave for the wrong authority.
The fix is not "ban non-ASCII domains." That punishes legitimate internationalized names and still leaves ASCII tricks such as misleading subdomains, userinfo, numeric IP forms, and redirect hops. The fix is to make one parsed authority record the only object that can both reach the approval UI and authorize credential injection. Then test the ugly inputs before they reach production.
The approval card must describe the request that will run
An approval prompt must come from the same canonical request object that the HTTP client will execute. Anything else creates two sources of truth: a display string for people and a transport string for code. That split is where hostname confusion turns into credential disclosure.
Build the request in this order:
- Accept the raw URL as untrusted input and parse it once with the URL implementation chosen for the gateway.
- Reject unsupported schemes, malformed authorities, embedded credentials, and inputs whose canonical form fails your hostname rules.
- Derive a structured authority record containing scheme, canonical host, effective port, and a flag for whether the input had a trailing root dot.
- Render the approval card from that record, then give the same record to the code that selects and injects credentials.
- Require a fresh authorization if a redirect, retry target, proxy target, or alternate address changes the authority record.
A raw URL is evidence, not authority. Keep it in the activity record because it helps explain what the agent attempted. Do not use it for credential matching, approval caching, or destination display by itself.
For HTTP, the authority is more than a bare hostname. https://api.example.test:8443/ and https://api.example.test/ may lead to different services and certificate policies. The default port can be omitted in the display after the parser establishes it, but a non-default port belongs on the card. Likewise, show the scheme. Sending a bearer token over http instead of https changes the risk even when the host text is identical.
RFC 3986 defines the host portion as an IP literal, IPv4 address, or registered name. That is useful syntax, but it does not tell an approval system what a person can safely recognize. Treat parsing as a prerequisite, then build a security-specific display around the parsed result.
Punycode is an identifier, not a friendly name
Punycode exists so DNS can carry internationalized labels using ASCII. An A-label begins with the ASCII xn prefix followed by two hyphens, while a U-label is the corresponding Unicode form. The conversion is reversible only when the label is valid under the applicable IDNA rules. That distinction matters because a string that merely resembles an A-label is not automatically valid or safe to display as Unicode.
RFC 5891 requires an IDNA-aware lookup application to validate an apparent A-label before treating its decoded Unicode output as a U-label. In particular, when an application decodes an A-label for native-language display, it should verify that converting it back produces the original label. That round trip is not academic. Without it, a UI can turn malformed ASCII into convincing Unicode text and hand the reviewer a story the transport never actually told.
The approval card should show both forms whenever the input is internationalized:
Destination
https://xn--example-ascii-label.test
Unicode rendering: example-unicode-label.test
Port: 443
Credential: deploy token
Put the canonical ASCII hostname on its own line in a fixed-width or otherwise highly legible treatment. Do not hide it behind a disclosure control. The Unicode rendering helps a human recognize a legitimate service, but the ASCII form is the durable identifier that should match allowlists, audit entries, cache keys, and the actual DNS lookup path.
Do not decode every label just because it starts with the A-label marker. Decode, validate, re-encode, and compare. If validation fails, show the literal ASCII label with a clear warning that the hostname failed IDNA validation. The safe action is to deny credential injection, not to guess what the agent meant.
This is one place where a popular recommendation fails: "Always display Unicode because punycode looks suspicious." That advice makes a friendly UI, but it removes the only representation that stays stable across fonts and scripts. Displaying only ASCII is also poor practice for a legitimate internationalized service. Show both, make ASCII authoritative, and ensure both come from one validated parser result.
Mixed scripts deserve a warning, not a false verdict
A hostname that combines Latin and Cyrillic characters can look almost identical to an ASCII hostname. For example, a label can contain a Cyrillic small letter that looks like Latin a, c, e, o, p, or x in many fonts. A reviewer scanning an approval card can miss the substitution, especially when the meaningful part of the name is short.
Unicode Technical Standard #39 calls this a mixed-script confusable when strings are visually confusable and the result is not a single-script confusable. It also describes whole-script confusables, where a label uses one script but resembles a label in another script. The standard is candid about the limit: confusability depends on fonts, contextual shaping, and human familiarity. A detector can identify risk, but it cannot prove that a name is deceptive.
That is exactly why a gateway should not turn a mixed-script check into an automatic blocklist. A Japanese, Korean, Chinese, Greek, or multilingual organization may have a legitimate domain that fails a simplistic rule. Instead, use the signal to change the approval posture:
- Mark any label with a mixed-script result as requiring explicit per-call approval.
- Render scripts and code points in a detail view when a reviewer expands the host.
- Compare the hostname's UTS #39 skeleton against protected internal names and explicitly configured high-value destinations.
- Deny automatic credential reuse if the destination is confusable with a protected name, even if it has not appeared before.
- Record the detector version and outcome in the activity log so a later review can reproduce the decision.
The skeleton is a comparison artifact, not a replacement hostname. UTS #39 explicitly says skeleton results are not suitable for display, storage, or transmission as identifiers. Store the canonical ASCII host. Use a skeleton only to ask a narrow question: "Does this candidate resemble a name we have decided deserves extra protection?"
A protected-name set should stay small and intentional. Include your own deployment endpoints, package registries, source control hosts, identity providers, and payment or production APIs. Do not generate a giant list of every public domain that might be important. You will create warnings nobody reads, and the one warning that matters will look routine.
Invisible code points turn review into a rendering problem
Invisible characters are worse than obvious non-ASCII characters because the reviewer cannot reliably see them. Depending on the character and the software involved, they can affect joining behavior, text direction, line breaks, or glyph selection. Some may be rejected by IDNA. Others may be mapped or handled differently by libraries. The approval flow must not depend on a reviewer spotting an absence of ink.
There are two separate questions that teams often merge:
- Is this code point valid in an IDNA hostname label?
- Can this code point make the approval display misleading, the log ambiguous, or the comparison code inconsistent?
Passing the first question does not settle the second. IDNA has contextual rules for some code points, and its lookup validation rejects several categories of invalid input. But an HTTP gateway also deals with raw URLs, UI rendering, JSON logs, copied text, and possibly non-DNS host forms. Security review needs rules for that whole route, not only DNS validity. RFC 5891 says IDNA is for domain names, not arbitrary free text. Keep it confined to hostname processing and do not pretend it sanitizes a complete URL.
For an approval screen, reject a hostname before credential injection if its parsed Unicode rendering contains a default-ignorable code point, a bidirectional control, or a character your chosen IDNA implementation reports as invalid. This is deliberately stricter than "try the lookup and see." An anonymous public browser may choose to attempt a lookup. A credential gateway should not send a token while investigating an ambiguous string.
When you log a rejected input, preserve two forms: a safely escaped code-point sequence and the original byte sequence or UTF-8 text in a field that does not undergo silent normalization. A good activity entry contains text such as:
raw_host_escaped: "api\\u200d.example.test"
parsed_host_ascii: null
rejection: "default-ignorable code point in hostname display"
credential_attached: false
Do not put the raw hostname into a sentence that an operator will skim. Logs often become the next approval UI during incident response. If an invisible character becomes invisible again in a terminal, you have merely moved the trap.
A trailing dot is data, even when DNS treats it as the root
A hostname ending in a dot is not cosmetic punctuation. In DNS presentation form, the final dot denotes the root label and marks the name as absolute. RFC 1034 explains that every complete domain name ends in the root label and that the printed form therefore ends in a dot.
That does not mean every HTTP component treats api.example.test and api.example.test. the same way. One parser may preserve the dot in its hostname property. Another may normalize it before connection. A certificate verifier, cookie implementation, proxy, allowlist, or redirect cache may use a third behavior. The only safe rule is to decide whether your gateway treats them as equivalent and test that decision across every component that handles the request.
For a credential gateway, I recommend preserving the input fact and normalizing the authority only after a documented comparison rule. Store both:
input_host: "api.example.test."
canonical_dns_name: "api.example.test"
had_root_dot: true
Then use canonical_dns_name for matching the destination record, but show the final dot on the approval card when it was present. The reviewer should see that the agent supplied a slightly unusual form. A normal dot should not silently widen authority. If a credential was approved for api.example.test, the request for api.example.test. can reuse that approval only if the parser, resolver, TLS name verifier, and gateway comparison rule all agree on the same destination.
Do not trim a trailing dot with generic string code before parsing. Generic trimming tends to grow into "clean up the host," which soon removes whitespace, punctuation, or Unicode separators that should have caused rejection. Parse first. Normalize only attributes with a written semantic rule.
The parser test corpus needs hostile cases, not examples from a slide deck
A hostname test suite should assert agreement between parsing, canonicalization, display, credential matching, connection setup, and logging. Testing a helper that converts a Unicode label to ASCII is useful, but it does not prove the request path is safe.
Use your production URL runtime for the first pass. The following Node script exercises the WHATWG URL parser and reports the fields that an approval gateway should compare. It intentionally prints JSON so diffs are readable in CI.
const cases = [
"https://example.test/",
"https://example.test./",
"https://münich.example.test/",
"https://xn\u002d\u002dexample-ascii-label.test/",
"https://pаypal.example.test/",
"https://api\u200d.example.test/",
"https://[email protected]/",
"https://example.test:8443/",
"https://127.0.0.1./"
];
for (const raw of cases) {
try {
const u = new URL(raw);
console.log(JSON.stringify({
raw,
href: u.href,
protocol: u.protocol,
hostname: u.hostname,
host: u.host,
port: u.port,
username: u.username,
passwordPresent: u.password.length > 0
}));
} catch (error) {
console.log(JSON.stringify({ raw, rejected: error.message }));
}
}
The expected output shape matters more than a universal expected string because runtimes change and host parsing rules differ by platform. Every accepted case should produce exactly one canonical hostname that reaches all later stages. Every rejected case should prove that the gateway attaches no credential and sends no network request.
Expand the corpus with labels from scripts your team actually encounters, Unicode dot-like characters, percent encoding in the host, leading combining marks, brackets and IPv6 forms, upper-case A-labels, malformed A-labels, empty labels, local names, and redirects. Add cases from bug reports. The test suite becomes useful when it contains the strings that made an engineer swear at a log file, not when it contains ten variations of example.com.
The WHATWG URL Standard is a worthwhile baseline for web URL behavior because it specifies host parsing, percent-encoded-host failures, Unicode-to-ASCII processing, and IPv4 edge cases. It is not permission to assume every HTTP stack, DNS library, or UI widget has identical behavior. Run the corpus through your actual stack.
Redirects need a new authority decision
The first approval does not authorize every host that a request might visit later. Redirect handling often lives below the application code that displayed the prompt, which makes it a common place to accidentally carry credentials forward.
Suppose an agent requests https://build.example.test/artifact. The user approves a deployment token for that host. The response returns a redirect to https://downloads.example.test/file, or worse, to a lookalike Unicode hostname. If the client follows redirects automatically and retains the Authorization header, the gateway has bypassed the approval boundary.
Use a simple rule: an HTTP redirect that changes scheme, canonical hostname, or effective port invalidates the prior credential authorization. The gateway may follow the redirect without credentials if that behavior is safe for the operation, but it must stop before injecting a credential at the new authority. Show a new approval card built from the newly parsed URL.
Also separate an origin change from a path change. A redirect within the same canonical scheme, host, and port may be eligible to retain the authorization if the operation's path rules permit it. Do not reduce this to a text-prefix comparison. https://api.example.test.evil.test/ starts with a reassuring string and belongs to a different host.
For bearer tokens, default to stripping Authorization on every cross-authority redirect. For client certificates and SSH-style credentials, the transport layer may choose an identity before a redirect is even possible, so the gateway needs an equivalent rule at connection setup. The principle stays the same: authorization binds to a parsed authority, not to an agent's initial intention.
Credential selection needs exact boundaries
A gateway that holds several API keys must decide which one can go to a host. This is not a UI concern. It is the point where a display mistake becomes a network action.
Store a credential binding as structured data, for example:
{
"scheme": "https",
"host_ascii": "api.example.test",
"port": 443,
"allow_subdomains": false,
"require_per_call_approval": true
}
Avoid a rule such as host.endsWith("example.test"). It accepts notexample.test, and a careless variant that checks for a preceding dot can still mishandle Unicode normalization or a trailing root dot. If you support subdomains, split canonical ASCII labels and compare labels from right to left. api.example.test may match the parent example.test only when the binding explicitly permits subdomains. The parent must never match the child in reverse.
Keep IP literals separate from registered names. Do not reverse-resolve an IP and then apply a hostname credential rule. DNS names can change, and reverse DNS is not proof that an IP is authorized for an API credential. Likewise, do not resolve a hostname and approve every address it returns. The credential binds to the hostname used for TLS and HTTP authority, while connection controls can separately limit private, loopback, link-local, or otherwise disallowed addresses.
A gateway should also distinguish a credential binding from an approval. The binding answers "could this credential ever be used here?" The approval answers "did a person authorize this agent process to use it for this request or session?" Blurring those questions is how an allowlist becomes an unattended signing oracle.
Audit records must preserve what the reviewer saw
When an approval later looks wrong, operators need to answer three questions: what did the agent send, what canonical authority did the gateway execute, and what exact text did the reviewer see? A single rendered URL cannot answer all three.
Record these fields separately:
- the raw URL or a safely escaped representation of it;
- parsed scheme, canonical ASCII hostname, effective port, and path;
- validated Unicode hostname rendering, if one exists;
- hostname risk flags such as trailing root dot, mixed-script result, confusable protected-name match, and invisible-character rejection;
- approval decision, credential identifier, and whether the gateway attached credentials.
Make the approval record immutable before the client starts the request. If the client later follows a redirect, create a linked child record with its own parsed authority and decision. A log line that says "approved request succeeded" is not enough when the interesting fact is that the first host returned a Location header to a second host.
Sallyport's split between a Sessions journal for agent runs and an Activity journal for individual calls is a sensible shape for this evidence: the session tells you which process received authority, while the call record tells you which destination used it. Its hash-chained audit log can verify the history offline, but the useful fields still have to be captured before the action occurs. A tamper-evident empty field is still an empty field.
Make suspicious hostnames expensive for the agent, not confusing for the reviewer
The safest approval UX does not ask a person to become a Unicode specialist in two seconds. It makes risky forms cost the agent more authority while giving the reviewer enough evidence to make a clear decision.
Use this behavior matrix:
| Host condition | Gateway action |
|---|---|
| Plain canonical ASCII hostname with an exact credential binding | Normal session or per-call behavior |
| Valid internationalized hostname | Show ASCII and Unicode forms, then apply normal binding rules |
| Mixed-script or protected-name confusable | Require per-call approval and show code-point detail on demand |
| Trailing root dot | Preserve and display the dot, compare only through the documented canonical rule |
| Invalid A-label, invisible control, unsupported separator, or parser disagreement | Deny before any credential or connection attempt |
| Redirect to a changed authority | Stop credential forwarding and request a new approval |
Do not make the warning card theatrical. A wall of red text teaches people to click through. Put the reason in plain language: "This hostname mixes Latin and Cyrillic characters" or "This hostname contains an invisible Unicode character." Then present the canonical ASCII host, the requested credential, and the action. People approve actions, not lectures.
The first test to add is a request that looks like one protected host to a casual reader but parses to another authority. Run it through the exact agent shim, approval UI, credential selector, HTTP library, redirect code, and audit logger. If any stage produces a different hostname string without causing a denial, the gateway still has two truths. Fix that before adding another policy toggle.
FAQ
Why are Unicode domains dangerous in approval prompts?
They can approve a request to a different host if the approval screen and the HTTP client do not use the same parsed, canonical destination. The risk is not that Unicode itself is unsafe. The risk is treating a human-friendly rendering as the authority while a different component decides where credentials go.
Is punycode safe to show in an approval dialog?
Punycode is an ASCII encoding for internationalized domain labels, not a safety verdict. A label can decode correctly and still look confusing to a reviewer, so an approval screen should show the canonical ASCII hostname as the authority and the Unicode form only as supporting context.
Does a trailing dot change an HTTP hostname?
A trailing dot usually denotes the DNS root and can make a hostname an absolute DNS name. HTTP stacks may preserve it, normalize it, reject it, or handle it differently in connection pooling and certificate checks. Treat it as a meaningful input until your exact parser and transport prove otherwise.
Should every mixed-script hostname be blocked?
No. A mixed-script hostname can also be a legitimate name in a language that uses more than one script, and a single-script hostname can still imitate an ASCII name. Mixed-script detection is a useful warning signal, not a permission decision.
What are invisible characters in a hostname?
Invisible characters are Unicode code points that may have no visible glyph, may affect joining or direction, or may disappear in a particular font. They are dangerous in a security prompt because two strings can look the same while remaining different inputs to parsing, display, logging, or comparison code.
What should an agent approval prompt display for an HTTP request?
Approve the parsed scheme, canonical hostname, port, credential identity, HTTP method, and an unambiguous path summary. Do not approve a raw URL string alone, and do not allow a redirect or retry to carry that approval to a new authority.
Can a proxy solve Unicode hostname approval problems?
Only if the proxy participates in the same canonicalization and authorization boundary as the client that injects credentials. A proxy log can be useful evidence, but it cannot repair an approval decision that was made over a differently parsed URL.
Can I just allowlist approved domains?
An allowlist is necessary for high-value credentials, but string matching alone is not enough. Store canonical authority records, compare host boundaries rather than suffix text, preserve ports where they matter, and force a new approval when a request falls outside the stored authority.
Which components need Unicode hostname tests?
Test each runtime that can parse, display, resolve, connect, or redirect a URL. That usually includes the agent shim, the gateway, the HTTP library, any browser view used for approval, the DNS resolver path, and the audit-log renderer.
Is secret redaction enough if the destination is suspicious?
No. Redaction protects the secret after or during logging; it does not stop a credential from being sent to a destination that the reviewer misunderstood. Destination identity must be settled before the gateway attaches an Authorization header or client credential.