Skip to content
wannacode

Untrusted input

In short: Untrusted input is anything that comes from outside (a request, a form, a file); it is checked on the server against an allowlist, and on any error the request is rejected.

Problem

Headphones for -177 €

The shop sells headphones for 59 €. The form on the page does not let you enter a quantity below 1 or above 10, and the price sits in a hidden field:

<form method="post" action="/orders">
<input type="hidden" name="product" value="17">
<input type="hidden" name="price" value="59">
<input type="number" name="qty" min="1" max="10" value="1">
<button>Buy</button>
</form>

The server takes the fields and works out the total:

string PlaceOrder(Dictionary<string, string> form)
{
int price = int.Parse(form["price"]);
int qty = int.Parse(form["qty"]);
return $"Order: {qty} pcs at {price} € = {qty * price} €";
}
// From the page: the browser has checked the form
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));
// The same request, sent by hand
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "-3" }));
Output
Order: 2 pcs at 59 € = 118 €
Order: 2 pcs at 1 € = 2 €
Order: -3 pcs at 59 € = -177 €

The first request comes from the page. The other two do not. The attacker opened the browser’s developer tools, or sent the request with curl, and wrote whatever they liked:

POST /orders HTTP/1.1
Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3

The payment provider gets a total of -177 € and refunds 177 € to the card. The min="1" check was not there: the request never went through the form.

The idea

The trust boundary

The browser belongs to the buyer. Everything in it, from the form to the page’s JavaScript, the attacker can change or skip. Your code starts on the server. The place where data comes from outside in is the trust boundary.

Whatever crosses it is untrusted input until you check it:

  • the fields of the form, hidden ones too;
  • the address and the parameters in it;
  • the request’s headers and cookies;
  • the name, type and size of an uploaded file;
  • a reply from someone else’s system, for example from the payment provider.

Your site put the hidden field and the cookie there. But the buyer’s browser sends them back, and they may have been changed on the way.

Thinking like the attacker means asking about every field: what if it is negative? Empty? Huge? Text instead of a number? The id of someone else’s order? 10 MB instead of 10 characters?

Model validation in “Views with Razor” ([Range(1, 10)] and ModelState.IsValid) is exactly this kind of check on the server. Here we write it by hand to see what it does.

See it

What the server lets in

The fixed handler trusts nothing in the request. Here is how the two orders go through it:

string PlaceOrder(Dictionary<string, string> form)
{
string product = form.GetValueOrDefault("product", "");
string qty = form.GetValueOrDefault("qty", "");
if (!prices.ContainsKey(product))
{
return "Refused: no such product";
}
if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
{
return "Refused: the quantity is 1 to 10";
}
int count = int.Parse(qty);
int price = prices[product]; // from the catalogue
int total = count * price;
return $"Order: {count} pcs at {price} € = {total} €";
}
Step by step, in words
  1. Two orders arrive. One comes from the page, where the browser checked qty ≥ 1. The other was sent by hand: price 1, quantity -3. To the server both are only text.
  2. They cross the trust boundary. From here on nothing has been checked. PlaceOrder reads only product and qty; the price from the request is never read.
  3. First check: is the product in the catalogue? 17 is, for both. An unknown id would stop here.
  4. Second check: the quantity must be one of "1" … "10". "2" is on the list. "-3" is not: refused, and the order goes no further.
  5. The price comes from the catalogue: 59 €. What the request says about the price does not matter.
  6. The honest order: 2 × 59 € = 118 €. The forged one stopped at the boundary checks. Only what passed every check gets in.

Three rules:

  1. Do not read what you know. The price is in the catalogue. The request says only which product and how many.
  2. Check against an allow-list. The product must be in the catalogue. The quantity must be exactly one of the texts "1" to "10".
  3. If something does not pass, refuse. Every path that does not reach the order is a refusal.
using System.Text.RegularExpressions;
var prices = new Dictionary<string, int> { ["17"] = 59, ["23"] = 9 };
string PlaceOrder(Dictionary<string, string> form)
{
string product = form.GetValueOrDefault("product", "");
string qty = form.GetValueOrDefault("qty", "");
if (!prices.ContainsKey(product))
{
return "Refused: no such product";
}
if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
{
return "Refused: the quantity is 1 to 10";
}
int count = int.Parse(qty);
int price = prices[product]; // from the catalogue
int total = count * price;
return $"Order: {count} pcs at {price} € = {total} €";
}
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "-3" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "99", ["price"] = "1", ["qty"] = "1" }));
Output
Order: 2 pcs at 59 € = 118 €
Order: 2 pcs at 59 € = 118 €
Refused: the quantity is 1 to 10
Refused: no such product
Regex.IsMatch checks whether the text matches a pattern (a regular expression). ^ is the start of the text, \z is the end, and [1-9]|10 means one digit from 1 to 9, or exactly 10.

Try it

Where the price comes from

Finish the line where the handler decides the price:

string PlaceOrder(Dictionary<string, string> form)
{
    string product = form.GetValueOrDefault("product", "");
    string qty = form.GetValueOrDefault("qty", "");
    if (!prices.ContainsKey(product))
    {
        return "Refused: no such product";
    }
    if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
    {
        return "Refused: the quantity is 1 to 10";
    }
    int count = int.Parse(qty);
    int price = ;
    int total = count * price;
    return $"Order: {count} pcs at {price} € = {total} €";
}

Try it

Three requests

What does the fixed handler print for these three requests?

Console.WriteLine(PlaceOrder(new() { ["product"] = "23", ["price"] = "0", ["qty"] = "10" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = "11" }));
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = " 3" }));
Answer

The idea

Allowed, not forbidden

Many people’s first idea is to forbid the bad: “the quantity cannot be negative or zero”. That is a deny-list. The other way is to describe exactly the good: “the quantity is one of the texts from 1 to 10”. That is an allow-list.

Here are both on the same inputs:

using System.Text.RegularExpressions;
bool DenyList(string qty) => !qty.StartsWith("-") && qty != "0";
bool AllowList(string qty) => Regex.IsMatch(qty, @"^([1-9]|10)\z");
string Word(bool ok) => ok ? "accepted" : "refused";
Console.WriteLine($"{"input",-6}{"deny-list",-11}allow-list");
foreach (string qty in new[] { "2", "-3", "0", "500", "2.5", "1e3" })
{
Console.WriteLine($"{qty,-6}{Word(DenyList(qty)),-11}{Word(AllowList(qty))}");
}
Output
input deny-list allow-list
2 accepted accepted
-3 refused refused
0 refused refused
500 accepted refused
2.5 accepted refused
1e3 accepted refused

The deny-list let 500, 2.5 and 1e3 through. Nobody thought of them when writing it. The attacker tries exactly what you did not think of. With an allow-list, the unknown is refused without you having to foresee it.

So: describe what a correct input looks like (type, length, characters, range, a value from a known list) and refuse everything else.

More precisely, if you are curious
  • Valid does not mean yours. 1042 is a perfectly correct order number, but it may belong to another buyer. A format check is not enough: the server also checks that the order belongs to the logged-in buyer. Without that you have one of the most common holes in real sites (IDOR, insecure direct object reference).
  • Normalise, then check. If the system further on reads the input differently from the check (spaces, upper and lower case, Unicode, encoding in the address), check the input after it has been normalised. Otherwise the check looks at one thing and the system gets another.
  • Limit the size. Every field has an upper limit on its length, and every uploaded file on its size. Otherwise a 50 MB “name” eats the server’s memory.
  • Regular expressions and the end of the text. Regex.IsMatch looks for the pattern anywhere in the text: without ^ and \z it also lets "500" through. In .NET $ also accepts a \n at the end, which is why the end is \z.
  • In a real project validation is declarative. Attributes like [Range(1, 10)] and [RegularExpression], which ASP.NET Core checks during model binding. The rules are the same: an allow-list, refuse on error, check on the server.

Problem

The check that lets things through on an error

The sale has a limit: at most 10 headphones per order. The check asks a table of limits, and for an unknown product it throws an exception. The code catches the exception, writes it to the log and carries on:

var limits = new Dictionary<string, int> { ["17"] = 10, ["23"] = 50 };
int LimitOf(string product)
{
if (!limits.TryGetValue(product, out int limit))
{
throw new ArgumentException($"unknown product {product}");
}
return limit;
}
bool MayBuy(string product, int qty)
{
bool allowed = true;
try
{
allowed = qty <= LimitOf(product);
}
catch (ArgumentException e)
{
Console.WriteLine($"log: {e.Message}");
}
return allowed;
}
Console.WriteLine($"17 x 3: {(MayBuy("17", 3) ? "yes" : "no")}");
Console.WriteLine($"17 x 500: {(MayBuy("17", 500) ? "yes" : "no")}");
Console.WriteLine($"99 x 500: {(MayBuy("99", 500) ? "yes" : "no")}");
Output
17 x 3: yes
17 x 500: no
log: unknown product 99
99 x 500: yes

500 items of product 99 went through. The error in the check became “allowed”.

Try it

Find the line

Which line makes the order pass when there is an error?

Pick the line with the bug.

This is the rule fail closed. The unknown, the empty, an error in the check and an exception you did not expect all lead to “no”. The opposite (failing open) is convenient until someone finds the input that breaks the check.

Exercise

The discount code

Solve the task in your editor.

The coupon. The cart is 120 €. Write Discount(code), which returns the total after the discount, or “refused”. The codes are AUTUMN26 (25%) and STUDENTS (10%). Let through only exactly 8 characters from A to Z and 0 to 9, and only a code that is in the table. Everything else is refused. Try these inputs:

Expected
"AUTUMN26": 90 €
"autumn26": refused
"AUTUMN26' OR '1'='1": refused
"": refused
"STUDENTS": 108 €
"NOPE1234": refused

autumn26 is refused: the rule says capital letters, not “fix it for the buyer”. The shop may decide to accept lower case, but then it writes that into the rule and checks that instead.

More tasks

  • The delivery address. Describe with rules what a postcode (4 digits in Bulgaria), a town and a phone number look like. Which of them can be an allow-list, and which only a limit on length and characters?
  • Someone else’s order. GET /orders/1042 returns order 1042. The input 1042 passes every format check. What else must the server check?

Test

Check yourself

1. The quantity field on the page is <input type="number" min="1" max="10">. Does the server need to check the quantity?
2. Which of these is untrusted input for the shop’s server?
3. Why is an allow-list safer than a deny-list?
4. The limit check throws an exception because it does not know the product. What should the code decide?
5. The product’s price sits in a hidden field of the form. What does the server do with it?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·