Filters as data, and never trusting the input
A screener is the first thing you have built where a person types something and it reaches an API. That changes the rules.
Filters are values, not code
Same principle as the threshold rules in course 1, and it matters more here:
type Filter = {
field: "market_capitalization" | "earnings_share" | "dividend_yield" | "avgvol_1d";
op: ">" | "<" | "=";
value: number | string;
};
A union type for field, not string. That single choice means a typo is a compile error rather than a runtime {"errors":{"filters.0.field":[...]}}, and it means the set of screenable fields is written down somewhere a person can read.
Serialising to what the API wants is then one function:
const encoded = encodeURIComponent(
JSON.stringify(filters.map((f) => [f.field, f.op, f.value])),
);
The allowlist is the security boundary
Here is the reasoning, and it is worth following rather than memorising.
Your proxy forwards query parameters upstream. A screener puts user input into those parameters. So user input now influences a request made with your API key. That is a new kind of exposure and it did not exist in course 1, where every request was for a ticker you had written into a file yourself.
The defence is not to sanitise the input. It is to never pass the input through at all: accept a structured filter, validate every field against a fixed list of allowed names and operators, and build the upstream query from the validated values. What reaches the API is a string your code constructed, not a string a person typed.
const FIELDS = new Set(["market_capitalization", "earnings_share",
"dividend_yield", "avgvol_1d", "sector"]);
const OPS = new Set([">", "<", "=", ">=", "<="]);
function safe(f: unknown): Filter | null {
// validate, or return null and drop the filter — never "clean it up"
}
Reject rather than repair. A filter you had to fix was a filter you did not understand, and quietly correcting it means the results do not match what the person asked for.
Guard the limit too
limit is user input the moment it is a control on your page, and 500 rows was verified to work in one call. Someone hitting your deployment in a loop with limit=500 spends your allowance, not theirs.
Clamp it server-side:
const limit = Math.min(Math.max(Number(raw) || 25, 1), 100);
Note that this happens in the proxy, not in the page. A control in the browser is a suggestion; the server decides.
Validate the response as well as the request
Same discipline in the other direction. The screener answers { "data": [...] }, and every row should be narrowed before your table sees it:
const body: unknown = await res.json();
const rows = Array.isArray((body as { data?: unknown }).data)
? (body as { data: unknown[] }).data.filter(isScreenRow)
: [];
One check at the boundary. Everything downstream can then be trusted, which is what stops defensive ?. spreading through every component.
Try it now
Add a filter control, then attack your own proxy with curl: send a field name that is not on your list, an operator like LIKE, and limit=100000. All three must be refused by the server even though your page would never send them. If any gets through, your allowlist is decoration.