The Parser That Works Because Its Bug Is Harmless
Two mini-languages share one parser that runs both resolvers unconditionally and strips the first and last character from every token - and it is correct only by coincidence.
Every configuration format grows a small expression language eventually. It starts as a literal, then somebody needs “the value from the route”, then “the current user”, and within two releases you have a syntax with sigils and braces and no grammar written down anywhere. The dangerous version of this is not the one that fails — it is the one that produces a plausible wrong answer for an input nobody has tried yet, because a config DSL has no compiler and no test suite, and the first person to find the edge is a user in production.
Part 6 counted the parses a payload endures. This part reads the two lines of DSL that sit on top of it — bind: and transform: — and the twenty lines of C# that implement both. I want to be precise about the finding, because it is unusual: the parser has a genuine bug, the bug is currently harmless, and the reason it is harmless is a coincidence about string lengths.
Two mini-languages, one colon
Here is the surface a route author sees:
- upstream: /orders/{customerId}
method: POST
use: downstream
downstream: orders-service/orders
payload: create_order
bind:
- customerId:{customerId}
- createdBy:@user_id
transform:
- createdBy:created_by
bind: writes a value into the outgoing payload from either a route parameter ({name}) or a built-in token (@name). transform: renames a key in the outgoing payload. Both are key:value pairs in a YAML string, both are parsed by Split(':'), and both live in one method.
foreach (var setter in route.Bind ?? Enumerable.Empty<string>())
{
var keyAndValue = setter.Split(':');
var key = keyAndValue[0];
var value = keyAndValue[1];
commandValues[key] = _valueProvider.Get(value, request, data);
var routeValue = value.Length > 2 ? value.Substring(1, value.Length - 2) : string.Empty;
if (data.Values.TryGetValue(routeValue, out var dataValue))
{
commandValues[key] = dataValue;
}
}
— PayloadTransformer.cs:64-75
Both resolvers run on every entry, unconditionally. There is no branch on whether the token starts with @ or {. Line 69 asks ValueProvider for the token; line 70 strips the first and last character and asks route data for the result; line 73 overwrites the first answer if the second one hit. Last writer wins.
Line 70 was written for {id}. "{id}".Substring(1, 2) is "id", which is exactly the route-data key. For the brace syntax the strip is precisely correct — it is a hand-rolled unwrap of a route-template placeholder.
Applied to @user_id, it produces user_i. "@user_id" has length 8; Substring(1, 6) takes characters 1 through 6 and drops the trailing d. That string is then looked up in route data.
And here is ValueProvider, the entire built-in token vocabulary:
internal sealed class ValueProvider : IValueProvider
{
private static readonly string[] AvailableTokens = {"user_id"};
public IEnumerable<string> Tokens => AvailableTokens;
public string Get(string value, HttpRequest request, RouteData data)
{
switch ($"{value?.ToLowerInvariant()}")
{
case "@user_id": return request.HttpContext?.User?.Identity?.Name;
default: return value;
}
}
}
— ValueProvider.cs:7-21
One token. @user_id resolves to the authenticated principal's name; anything else is returned verbatim.
Put the two together and trace createdBy:@user_id:
keyiscreatedBy,valueis@user_id.ValueProvider.Get("@user_id")returns the username. The correct answer is now in the payload.routeValuebecomesuser_i.data.Values.TryGetValue("user_i", out _)— there is no route parameter calleduser_i, so it returns false.- The correct answer survives.
Now trace customerId:{customerId}:
ValueProvider.Get("{customerId}")falls todefaultand returns the literal{customerId}. The payload now contains a brace-wrapped placeholder as a string value.routeValuebecomescustomerId.- The lookup hits, and line 73 overwrites the placeholder with the real segment value.
Both cases produce the right answer, and in both cases the wrong resolver ran first and wrote a wrong value into the payload before being corrected. For the @ case the correction is skipped by luck; for the {} case the correction is the whole mechanism.
Where it stops being harmless
The bug is that a mangled token is looked up in a namespace the author controls. Write this route:
- upstream: /audit/{user_i}
method: POST
use: downstream
downstream: audit-service/entries
payload: create_entry
bind:
- actor:@user_id
and data.Values now contains a key user_i. Step 4 above hits. The authenticated username resolved on line 69 is overwritten by whatever the caller put in that path segment. A route parameter named user_i shadows every @user_id binding on that route — including the one that identifies who is making the request.
That is a contrived name and I have never seen it in the wild. That is the point. Correct-by-accident is indistinguishable from correct until somebody names a route parameter badly, and no test, no type and no boot-time check stands between this codebase and that day. The version of this parser that is correct on purpose is three lines longer:
foreach (var setter in route.Bind ?? Enumerable.Empty<string>())
{
var separator = setter.IndexOf(':');
var key = setter.Substring(0, separator);
var value = setter.Substring(separator + 1);
if (value.StartsWith("@"))
{
commandValues[key] = _valueProvider.Get(value, request, data);
}
else if (value.StartsWith("{") && value.EndsWith("}")
&& data.Values.TryGetValue(value.Substring(1, value.Length - 2), out var routeValue))
{
commandValues[key] = routeValue;
}
else
{
commandValues[key] = value;
}
}
One branch per syntax, and the sigils that the DSL already uses become the discriminator they were always meant to be.
Three more edges on the same twenty lines
Split(':') with no colon throws per request, not at boot. bind: [- customerId] — a typo, a missing colon — produces keyAndValue[1] on a one-element array and an IndexOutOfRangeException out of the endpoint delegate. There is no validation of bind: or transform: syntax anywhere at startup, which is a striking omission in a codebase that does validate handler names and policy names at boot.
Split(':') with two colons silently truncates. bind: [- callback:https://example.com/hook] yields keyAndValue[1] == "//example.com/hook". IndexOf(':') plus two substrings, as above, fixes it.
transform: can throw on a name collision.
commandValues.Remove(before);
commandValues.Add(after, value);
— PayloadTransformer.cs:87-88
IDictionary<string, object>.Add over an ExpandoObject throws ArgumentException when the key already exists. So transform: [- createdBy:created_by] on a payload that already carries a created_by field — because the template declared one, or because the caller sent one — is a 500. commandValues[after] = value; is the same operation with last-writer-wins semantics, which is what every other write in this method already uses.
What bind: costs a route that has no template
One structural consequence of putting both mini-languages in PayloadTransformer is that declaring either one drags the whole payload machinery onto the route:
public bool HasTransformations(string resourceId, Route route)
{
if (!string.IsNullOrWhiteSpace(resourceId))
{
return true;
}
if (route.Bind.IsNotEmpty())
{
return true;
}
return route.Transform.IsNotEmpty() || _payloads.ContainsKey(GetPayloadKey(route));
}
— PayloadTransformer.cs:28-41
A single bind: entry flips hasTransformations to true, which means the request body is read to the end by PayloadBuilder, parsed twice by GetObject (the template lookup misses, so the no-template branch runs), mutated, and re-serialised for the wire. Adding one line of YAML converts a route from a pass-through stream to a full buffer-and-reparse. That is defensible — you asked for the body to be modified — but it is invisible at the point of decision, and there is no way to bind a value into an outgoing request without paying it.
The reverse also holds and is more useful: a route with bind: but no payload: template is not whitelisted. The caller's entire body passes through with the bound fields added on top. If you were relying on bind: for anything security-adjacent — “the gateway sets createdBy from the token” — the caller can still send their own fields alongside it, and only a payload: template restricts them. Two features that look adjacent in the YAML have completely different trust properties, and nothing says so.
The overwrite above it
One more line deserves attention, because it is the only place in the transformer that touches data without being asked to:
if (!string.IsNullOrWhiteSpace(resourceId))
{
var resourceIdProperty = string.IsNullOrWhiteSpace(route.ResourceId?.Property)
? _options.ResourceId?.Property
: route.ResourceId.Property;
if (string.IsNullOrWhiteSpace(resourceIdProperty))
{
resourceIdProperty = ResourceIdProperty;
}
commandValues[resourceIdProperty] = resourceId;
}
— PayloadTransformer.cs:51-62
When resourceId.generate: true is set, the gateway mints a GUID and writes it into the payload under the configured property, defaulting to id. The assignment is unconditional — a caller-supplied id in the body is silently replaced. For POST that is exactly right and is the good half of the feature: the edge mints the identifier, returns it in a Resource-ID header, and the caller never has to trust a client-generated key. It is the Resource-ID identity-at-the-edge pattern done properly, and it is one of the nicest things in the codebase.
But RequestProcessor.cs:91 excludes only GET and DELETE from minting, and DownstreamHandler.cs:237 surfaces the header only on POST. A PUT therefore mints a GUID, overwrites the id in the caller's body with it, forwards it downstream, and never tells the caller what it was. The three-level property fallback above it is careful and the exclusion list below it is not, and the combination turns a good idea into a data-integrity hazard on exactly one verb.
Next, three predicates and one flag — three components deciding independently whether a route needs authentication.