URL encoding bugs: test agent API inputs before calls
URL encoding bugs can send an agent to the wrong API route or alter query data. Test paths, queries, percent escapes, and special characters first.

A URL is not a harmless string. It is a structured instruction whose delimiters decide the host, route, parameter names, and values. If an agent combines user supplied text with a URL incorrectly, it can call a different endpoint from the one a developer reviewed.
I have seen teams approve a request that looked like GET /records/alice in a tool transcript, then spend an afternoon finding out the server received a route with an extra slash, a duplicate query parameter, or a decoded traversal sequence. The agent did not need an exotic exploit. It only needed ordinary text such as a+b, %2F, &admin=true, or a name in a non-ASCII script.
URL encoding bugs are easy to dismiss because an HTTP client often “handles encoding.” It handles some serialization, according to its own API and defaults. It cannot decide whether a user value belongs in a path segment, a query value, a form body, or nowhere at all. That decision belongs in the action definition and in its tests.
The request target, not the visible string, decides the call
A request can change meaning at each point where software parses or rebuilds its URL. The character sequence an agent produces is only the beginning. Your client library may normalize it, a reverse proxy may rewrite it, and the application framework may decode it before route matching or parameter binding.
RFC 3986 divides a URI into components: scheme, authority, path, query, and fragment. Within a path, / is a delimiter. Within a query, & and = acquire meaning in common conventions even though RFC 3986 does not define a universal query grammar. That distinction explains most failures that people lazily label “encoding issues.”
Consider an action intended to fetch one project:
GET https://api.example.test/projects/{project_id}
If project_id is north/ops, these two request targets are not equivalent:
/projects/north/ops
/projects/north%2Fops
The first has two path segments after projects. The second attempts to carry a literal slash inside one segment. Whether the second survives depends on the entire route from client to application. Some stacks decode %2F before matching and turn it into the first form. Others reject it. A client test that merely asserts “the URL was encoded” proves very little.
The same trap appears in queries. An action that means to search for a literal phrase should not build this:
/search?q=USER_TEXT
by replacing USER_TEXT with raw text. With red&limit=500, the final target can become:
/search?q=red&limit=500
The application now sees two query parameters. If the code creates a query object and supplies red&limit=500 as the value of q, it should emit:
/search?q=red%26limit%3D500
That is the concrete boundary to test: semantic field in, exact request target out, parsed semantic field at the destination.
Do not test with only letters and numbers. Those values hide the exact bugs that agents expose. An agent receives support tickets, issue titles, branch names, file paths, copied URLs, and prose. Real input includes delimiters.
A path parameter is one segment, not an unfinished URL
Treat a path parameter as a single segment unless the API contract explicitly says it accepts a path. This one rule removes a surprising amount of ambiguity.
Developers often concatenate paths because it reads well:
const target = base + "/projects/" + projectId + "/builds";
That code assigns no component-specific meaning to projectId. If the value contains /, ?, #, or %, the outcome depends on what later code does with target. It also invites a second mistake: someone sees an encoded value in a log, calls encodeURIComponent again “to be safe,” and produces a different identifier.
Build segments as data, encode each segment once, and join only the separators that the route owns. In JavaScript, this narrow helper makes the contract visible:
function pathSegment(value) {
if (typeof value !== "string" || value.length === 0) {
throw new Error("project id must be a nonempty string");
}
return encodeURIComponent(value);
}
const path = "/projects/" + pathSegment(projectId) + "/builds";
encodeURIComponent is appropriate here because it encodes /, ?, #, &, and = that would otherwise alter the path or begin a query or fragment. It does leave a small RFC 3986 set unescaped, including apostrophes and parentheses. That usually does not change path structure, but a strict API contract may require a tighter encoder. Decide that from the API specification, not from habit.
Do not use encodeURI for a single segment. It preserves URI delimiters because it expects a complete URI. If you pass north/ops through it, the slash survives and the route changes. This is a popular recommendation because the function name sounds right. Its scope is wrong.
A path can legitimately carry multiple segments, for example when an API says /{owner}/{repository}. Model that as two fields, not one free-form resourcePath string. If your endpoint truly needs an opaque identifier that may contain slashes, consider putting it in a query parameter or JSON request body instead. APIs that force opaque text through routing layers make everyone guess how encoded slashes behave.
There is also a routing decision that client encoding cannot repair. Many proxies and frameworks normalize dot segments such as . and .., collapse repeated slashes, or reject encoded separators. Ask the endpoint owner one direct question: does route matching occur before or after percent decoding? Then test against the deployed route, including its proxy. Documentation for a framework running alone on a developer machine does not answer that question.
Query strings need a declared grammar
A query string is not one escaped blob. It is a collection of fields whose grammar belongs to the API. You need to decide repeated names, empty values, arrays, booleans, spaces, and duplicate handling before an agent can call the endpoint safely.
The WHATWG URL Standard and browser-oriented APIs use form-style query serialization for URLSearchParams. In that convention, a space often becomes +, and a literal plus becomes %2B. Many server parsers use the same convention. RFC 3986 itself does not say that + means a space in a generic URI. Both facts matter when one component uses a generic parser and another uses a form parser.
Use a query builder rather than string templates:
const query = new URLSearchParams();
query.set("q", userText);
query.set("include_archived", "false");
for (const label of labels) query.append("label", label);
const url = "https://api.example.test/search?" + query.toString();
For userText = "C++ & systems", the exact spelling may be q=C%2B%2B+%26+systems. A server that applies form decoding should recover C++ & systems. Your regression test should expect the API's semantic value, not insist that every library use %20 for spaces. %20 and + can both represent a space in the query conventions you will encounter; a literal plus must remain a literal plus after parsing.
Repeated fields need an explicit decision. These are different contracts:
?label=bug&label=security
?label=bug,security
?label=["bug","security"]
The first is a repeated name. The second is one comma-containing value unless the API says otherwise. The third is a JSON-looking string, not JSON unless a server deliberately parses it. Do not tell an agent “pass labels in the URL” and leave the format implied. Give the action an array argument and make the action serialize it in the one form the endpoint accepts.
Duplicate scalar parameters are another quiet failure. A request with ?role=user&role=admin may yield the first value, the last value, an array, or an error depending on the framework. A security check that reads the first value while a downstream service reads the last creates a split decision. Reject duplicates for fields that should occur once, at the earliest component you control.
Fragments deserve a mention because they confuse debugging. #section normally never leaves the client as part of an HTTP request. If raw user input adds #, it can remove everything after it from a URL object before the request goes out. Encode it inside a path or query value when it is data. Do not rely on a log of the source string to tell you what reached the server.
Percent signs and decode order create different identifiers
Decode percent escapes once at a defined boundary. If two components decode the same input, a value that looked inert can turn into a delimiter after the first check has passed.
Take the text %252F. One percent decode produces %2F. A second percent decode produces /. This matters when validation happens between those two operations. A gateway might reject raw / in an identifier and permit %252F, then an upstream application might decode again and split the route. The same pattern applies to %252e becoming %2e and then ..
You should distinguish three values in both design discussions and tests:
- The raw wire spelling, such as
%252F. - The value after one percent decode, such as
%2F. - The application value after every parser and rewrite, such as
/.
Teams often call all three “the URL.” That blurred language causes bad reviews because people compare different stages without noticing.
RFC 3986 advises URI producers not to encode or decode the same string more than once. That is sound advice, but it is not an implementation plan. Define where your action serializer accepts decoded application strings, and define where the server accepts raw request bytes. Everything between those boundaries should preserve escaping or reject forms it cannot preserve.
Do not accept already encoded input as a convenience. An agent prompt that says “provide a URL-encoded project ID” asks the model to guess whether %2F is data or an instruction. The next layer cannot know whether it should preserve the percent sign or encode it to %25. Accept plain text fields, encode them once in the action layer, and reject malformed percent escapes only where raw URLs are intentionally accepted.
Unicode adds another boundary. A URL client normally turns text into UTF-8 bytes and percent encodes bytes that cannot appear directly in the chosen component. Server frameworks may normalize Unicode differently before looking up a user or resource. Keep identifiers in a canonical form at the application layer if your domain requires one. Do not try to solve Unicode identity with URL escaping. Escaping transports bytes; it does not decide whether two visually similar strings name the same account.
Test the whole route with adversarial but ordinary inputs
A useful encoding test has two observations: the request target emitted by the client and the values parsed by the receiver. If you only test either side, a proxy rewrite or framework decoder can still change meaning in the middle.
Start with a controlled echo handler in your own test environment. It should record the raw request target when the server runtime exposes it, then return its parsed path and query fields. Keep credentials out of this endpoint. Its job is to expose serialization, not authenticate anyone.
This small Node handler illustrates the response shape. It deliberately returns both a raw-looking request property and parsed values, because those fields catch different defects:
import http from "node:http";
http.createServer((req, res) => {
const url = new URL(req.url, "http://local.test");
const pairs = [...url.searchParams.entries()];
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({
requestTarget: req.url,
pathname: url.pathname,
queryPairs: pairs
}, null, 2));
}).listen(8787);
Send known cases to it and retain the expected output. For example, a query builder that receives C++ & systems should produce an output with one q pair whose parsed value is exactly C++ & systems. A hand-built query often reveals itself by returning two pairs or by changing the plus signs into spaces.
{
"requestTarget": "/search?q=C%2B%2B+%26+systems",
"pathname": "/search",
"queryPairs": [["q", "C++ & systems"]]
}
Your suite should cover a compact matrix, not dozens of random strings:
- Space, literal plus, percent sign, ampersand, equals sign, question mark, and hash.
- Slash and encoded slash within an identifier intended as one segment.
- Empty strings, missing optional fields, and repeated query names.
- A Unicode value and a value whose percent escapes resemble a second decoding pass.
- A complete URL pasted into a field that expects an identifier.
Use property-based tests if your team already has that practice, but do not hide the named cases behind generated examples. The named cases document why the boundary exists. When a regression occurs, encoded slash stays inside project_id is far more useful than a seed number.
Run the same integration tests through the path that production traffic uses. A direct test against an application process will not tell you whether a proxy rejects %2F, rewrites duplicate slashes, or chooses a different duplicate query value. If the production edge cannot be included in local tests, use a staging route with the same configuration and make that test part of a release check.
Give agents structured arguments, not URL construction freedom
An agent should choose an action and provide typed arguments. The action implementation should choose the HTTP method, allowed origin, route template, query grammar, headers, and encoding. Allowing the agent to hand over a complete URL collapses all those separate controls into one ambiguous string.
A narrow action contract might look like this:
{
"name": "get_project_builds",
"input": {
"project_id": "north/ops",
"branch": "release+candidate",
"limit": 25
}
}
The action code should validate project_id as an identifier, encode it as one path segment, put branch into a query builder, validate limit as an integer in the API's allowed range, and construct the URL from a fixed origin. The model never needs to see a bearer token or decide where an ampersand belongs.
This separation also prevents origin confusion. A string that begins with https://other.example has no place in an identifier field. If an action genuinely fetches a user supplied URL, make that a distinct action with a written allowlist, DNS and redirect behavior, and a clear reason it exists. Do not smuggle arbitrary fetch capability through a field named callback or file.
Be conservative with APIs that accept filter languages in query parameters. A field such as filter=status:open AND owner:me has two grammars: URL query serialization and the filter language itself. URL encoding keeps the filter in one query value. It does not make the filter safe. Parse or constrain that inner language separately, or offer typed filter fields instead.
Avoid putting secrets in URLs. Query strings end up in access logs, browser history in some contexts, telemetry, and error reports. Credentials belong in the authorization mechanism the API expects. Encoding an API token does not make its presence in a query safe.
Approval is useful, but it cannot repair a malformed request
Human authorization and correct serialization solve different problems. An approval can confirm that a recognized agent process may use a credential. It cannot reveal whether a percent sequence will turn into a slash at a downstream router, or whether a duplicated parameter changes privilege after parsing.
That distinction matters when someone argues that prompts or approval cards make strict request construction unnecessary. They do not. A person reviewing https://api.example.test/projects/%252Fadmin must mentally simulate every decoder in the route to know what will happen. That is not a fair security control, especially when an agent can make many calls in a session.
Put deterministic checks before the call reaches an approval surface:
- Accept structured fields rather than a preassembled request target.
- Fix the origin, method, and route template in the action definition.
- Serialize each path segment and query value once.
- Reject duplicate or malformed fields that the endpoint does not define.
- Test the final target through the deployed request path.
Sallyport can keep credentials out of an MCP-capable agent and require authorization for an agent process or for each use of a selected credential. That human control works best when the action layer already makes one unambiguous request.
Logs also need both levels of detail. Record the approved action and its safe arguments, then retain a redacted representation of the actual request target and the HTTP result. If a service has a sensitive query field, redact its value while retaining the parameter name and enough shape to diagnose accidental duplication. Never log authorization headers just because a request failed.
Route normalization can defeat a correct client
A perfectly serialized client request can still change at infrastructure boundaries. Proxies, load balancers, web application firewalls, and application servers each have opinions about duplicate slashes, dot segments, encoded separators, and invalid escapes. You need a route contract for the entire chain.
Take an endpoint whose client sends this path:
/files/reports%2F2025%2Fnotes
If the API expects one opaque file ID, the desired application parameter is reports/2025/notes. But a proxy that decodes before forwarding may send /files/reports/2025/notes. A router configured for /files/:id may reject it. Another router may match /files/:folder/:year/:name and call a different handler. Neither outcome proves that the client encoder failed.
Make a decision for each sensitive route. You can reject encoded slashes at the edge and document that IDs cannot contain slashes. You can preserve them all the way to the handler and test that property. Or you can redesign the endpoint so opaque data lives outside the path. What you should not do is leave behavior to version-specific defaults and call the result a security boundary.
Watch redirects too. HTTP clients may follow redirects automatically, and a redirected URL can carry a differently normalized path or query. For actions with credentials, define whether redirects are allowed, whether the destination origin must match, and whether authorization headers are stripped on an origin change. This is separate from percent encoding, but URL bugs and redirect rules often meet in the same request code.
If you operate several services, test disagreement deliberately. Send the same encoded path through the edge and directly to the application in a nonproduction environment. If their parsed path values differ, fix that before granting an agent access to the route. A split parser is an operational problem even if no attacker ever touches it.
A regression suite should preserve the meaning you intended
The point of URL tests is not to force one preferred escaping spelling. It is to preserve the relationship between action arguments and the server-side meaning of a request as libraries and infrastructure change.
Write assertions at three layers. Unit tests should verify that a path segment encoder turns a/b into a single encoded segment and that query serialization preserves & inside a value. Action tests should capture the exact method, origin, path, and query pairs sent to an echo handler. Integration tests should run through the production-like edge and assert the handler receives the expected route and parameters.
When an API contract is vague, write down the ambiguity and remove it. “Supports search text in the URL” is not a contract. State whether empty q is accepted, whether q=a+b means a plus or a space, whether repeated tag values are allowed, and whether %2F is permitted in IDs. Those details stop being incidental once an agent can generate calls from arbitrary human text.
Do not paper over a failing test by decoding input earlier. Earlier decoding often makes the case look normal while moving the ambiguity into a less visible layer. Keep raw user text as data until the component-specific serializer handles it. Then inspect what the receiver actually parsed.
Sallyport's Activity journal can make it easier to compare an agent's approved call with the result it returned, while its audit chain can be verified offline with sp audit verify. Use that trail to investigate a mismatch, not as a substitute for deciding what your endpoint accepts.
The first test I would add is painfully ordinary: an identifier containing /, a query value containing + and &, and a percent sign that must remain literal. If your action cannot state exactly what the server receives for those inputs, it is not ready to accept user supplied text from an agent.
FAQ
How do I test whether an API client encoded a URL correctly?
Test the bytes that leave the client, the URL your server or proxy receives, and the route or query values the application finally uses. These can differ because a client library serializes input, an intermediary normalizes it, and a framework decodes it. A test that only checks the final HTTP status misses the disagreement that caused it.
Can I use the same URL encoding function for paths and query strings?
No. A path segment identifies one part of a resource hierarchy, while a query component carries name and value pairs. Encoding a slash in a path has different consequences than encoding a slash inside a query value, so one generic escaping function is a poor contract.
Why does a plus sign become a space in an API query string?
A plus sign has no special meaning in the generic URI syntax described by RFC 3986. Many form-style query parsers still decode plus as a space because of application/x-www-form-urlencoded conventions. Treat plus as data and encode it as %2B when its literal value matters.
Should a slash in a path parameter be encoded as %2F?
Usually, encode a slash that belongs to user data as %2F before it enters a single path segment. Then verify that every component on the request path preserves it rather than decoding and splitting the route. Some server stacks reject encoded slashes, so an opaque identifier in the query or request body may be the safer API design.
Are double-encoded URL values dangerous?
They can. One decoder may turn %252F into %2F, while a second decoder turns it into a slash and changes routing or validation. Reject unexpected percent escapes after decoding once, and test the full request route rather than trusting a single component.
How should an agent send arrays in a query string?
?tag=a&tag=b is commonly a repeated parameter, while ?tag=a,b is one value containing a comma unless the API says otherwise. Agents should receive the API's declared representation, and tests should prove how empty values, repeated names, and ordering arrive at the server.
Is it safe to let an AI agent build a full URL from user input?
Put untrusted text in structured tool arguments, then let the action layer serialize each field according to its destination. Do not let an agent assemble a full URL with string concatenation. That boundary makes it possible to reject a foreign origin, a user-info component, or an unexpected fragment before any request runs.
What should I log when debugging URL encoding failures?
A log that prints a decoded URL can conceal the encoded bytes that changed the request. Record a safe, redacted representation of the final request target plus the parsed path and query fields. Never put credentials in a URL merely to make debugging easier.
Can human approval prevent URL encoding attacks?
An approval tells you that a process may make a call. It cannot tell whether %252e%252e%252f will be decoded twice by a proxy and become a different path downstream. Request construction needs deterministic validation before the approval boundary.
What special characters belong in URL encoding regression tests?
Use a local echo endpoint or a controlled test service and include spaces, plus signs, percent signs, encoded delimiters, Unicode, duplicate query names, and empty values. Compare the raw request target with the server's parsed fields, then retain those cases as regression tests whenever the client library, proxy, or API framework changes.