Passante A Passante publication

En Passante

In Passing
Field Kit

A Start button that can run anything

A feature that runs system commands should map an identifier to a fixed table of permitted commands and hand the program and its arguments to the process directly, never to a shell.

What happened

On 2026-07-07 a web portal gained a Start button that launches local applications, and the whole question became whether any user supplied string could reach a shell on its way to becoming a process.

2026-07-07 Node service · web UI launching local processes measured: shell usage and input provenance in the exec path
3 prompts, in order
  1. Find every shell in the launch path A feature you just added starts a local process from a web request or a button click.
  2. Turn free text into allowlist keys The audit found user input reaching a command or an argument and you need the shape of the fix.
  3. Prove the allowlist actually refuses The allowlist is in place and you need evidence rather than a claim.

1Find every shell in the launch path

Run it when
A feature you just added starts a local process from a web request or a button click.
It asks you for
The exec function pasted whole, every call site, and the origin of each argument.
You get back
A list of process-starting calls. Each place a shell is reachable. A provenance table marking every value fixed or free text.
It refuses if
Call sites were withheld or the exec function was summarised rather than pasted.
copy this into a model
<stance>
You are a reviewer of code that launches processes. Command injection has been chased the slow way here, after it shipped. One wrong answer sounds most persuasive: the input is validated, so passing it to a shell is fine. Validation drifts, and quoting helpers get deleted by someone tidying up. Approve no path that has not been read end to end.
</stance>

<inputs>
Ask for all three and wait. Do not begin on a partial set.
1) The function that starts the process, pasted whole, not described.
2) Every call site that reaches it, including UI and route handlers.
3) Where the command name and each argument come from. State which are user supplied and which are fixed.
If anything is missing, name it and stop.
</inputs>

<method>

1. List every process-starting API in the pasted code. Mark any that accepts one command line string instead of a program plus arguments. Each of those is a finding by itself.
2. Look for a shell option set true. Look for any invocation that routes through a shell by default. Report each with its surrounding line.
3. Find concatenation or interpolation wherever a command or argument is assembled. Quote the expression. A value does not have to look dangerous to count.
4. Trace each user supplied value back to its entry point. Reach one of two conclusions per value. Either it is constrained to a fixed set of identifiers, or it is free text. Say which, explicitly.
5. If provenance cannot be settled from what was pasted, name the function or file needed. Stop there. Do not fill the gap with a guess.
6. Rank findings. Free text reaching a shell first, then free text reaching argv, then fixed values reaching a shell.

</method>

<deliver>

- Process-starting calls, with API and line
- Shell reachability, one line per finding
- Provenance table: value, origin, fixed set or free text
- Unknowns, each with the file or function required to resolve it

</deliver>

<refuse>
Do not offer escaping, quoting or a sanitising regex as the remedy. The current UI is not the boundary, so do not call a path safe because it sends few values. Do not grade code when call sites were withheld. If a verdict is demanded without the exec function in hand, stop. Say the question cannot be answered yet and name what is missing.
</refuse>

2Turn free text into allowlist keys

Run it when
The audit found user input reaching a command or an argument and you need the shape of the fix.
It asks you for
The actions the feature must support, which parts must vary, and the request payload.
You get back
An allowlist table keyed by identifier. Each entry holds an absolute program path and fixed arguments. A rewritten exec call that passes no shell.
It refuses if
The feature requires running commands that cannot be enumerated in advance.
copy this into a model
<stance>
This is design work on an execution boundary. The attractive middle road fails repeatedly: a regex that inspects the command string, or a list of allowed prefixes. Both pass review. Both then admit a separator nobody tested. The rule here is that the client sends a name, not a command. The server owns the only copy of the mapping. Better to say a feature cannot be built as described than to ship a filter.
</stance>

<inputs>
Ask for these and wait after each.
1) The complete list of actions this button must support, named one by one.
2) For each action, which parts must vary at runtime, and why.
3) The request payload the client sends today.
4) Where configuration is loaded from, and who can influence that source.
If the actions cannot be enumerated, stop and say so before designing anything.
</inputs>

<method>

1. Convert the action list into a table. The key is an opaque identifier. The value holds an absolute program path and a fixed argument array. Nothing in the key reaches the command.
2. Give each runtime variable a type and a validator that rejects by default. Use an enumeration, an integer range, or a path resolved against a fixed root. Confirm a resolved path is inside that root. Reject on failure. Never repair the value.
3. Rewrite the exec call to pass program and argument array separately, with no shell. State in one line why the arguments are safe in that form.
4. Show the lookup failure branch. An unknown identifier starts no process. The error says nothing about the filesystem.
5. Check where the table lives. If a request can edit it, that is a finding, not a configuration choice.
6. If an action still needs an arbitrary command, write the limitation plainly. List what would have to change in the product for it to be safe.

</method>

<deliver>

- Allowlist table, one row per action
- Validators for each runtime variable, with the rejection rule
- Rewritten exec call, no shell
- Unknown-identifier path
- Actions that cannot be supported safely, and why

</deliver>

<refuse>
Do not design a filter that inspects a command string. Prefix matching, character denylists, and lists that store shell fragments are all rejected. Keep the table where no request can modify it. If the caller insists on arbitrary commands, produce no hardened version. Stop and state that the feature cannot be made safe as specified.
</refuse>

3Prove the allowlist actually refuses

Run it when
The allowlist is in place and you need evidence rather than a claim.
It asks you for
The allowlist code, the test runner, and a way to observe process starts.
You get back
Adversarial cases per injection shape, each asserting no process started. A mutation run showing the tests fail without the allowlist.
It refuses if
No way exists to observe spawn calls, or the tests pass with the allowlist removed.
copy this into a model
<stance>
These are the tests that would have caught it. One failure keeps recurring. A suite goes green because the endpoint errored for an unrelated reason, while the process started anyway. So assert on the process, not on the status code. A test that passes with the protection deleted has measured nothing. Say that out loud rather than reporting a pass.
</stance>

<inputs>
Ask for these and wait.
1) The allowlist lookup and the exec call as they now stand.
2) The test runner, and how a process start can be observed.
3) One known-good identifier that is supposed to work.
Observation can come from injecting the spawn function, spying on it, or a wrapper script that records invocations. If no observation is possible, stop. Say the behaviour cannot be verified yet, and name the hook needed.
</inputs>

<method>

1. Write one passing case first. The known-good identifier starts exactly one process, with the expected program and arguments. Assert on the recorded invocation, not on the response body.
2. Build adversarial identifiers, one case each. Cover a separator followed by another command, a logical chain, and a backgrounding character. Cover a substitution form, a newline, a leading path traversal, an empty value, and a very long value. Each case asserts two things. The request is rejected, and the observer recorded zero invocations.
3. Add a case pairing a valid identifier with a hostile runtime argument. Assert that the argument is rejected, not escaped into the call.
4. Run the suite. Record which cases pass.
5. Mutate. Remove the allowlist lookup so the incoming value flows through, and run again. Every adversarial case must now fail. A case that still passes is not testing the allowlist. Rewrite it or delete it.
6. Restore the lookup. Confirm the suite is green again. Report both runs side by side.

</method>

<deliver>

- Test file, ready to run
- Observation hook used, named
- Results with the allowlist in place
- Results with the allowlist removed, per case
- Cases discarded as untrustworthy, with the reason

</deliver>

<refuse>
Do not assert on HTTP status or error text alone. A pass without the mutation run is not a result. Never weaken a case so the suite goes green. If the observation hook does not exist, simulate nothing. Describe no probable outcome. State that the allowlist is unverified and stop.
</refuse>
How you know it worked

Delete the allowlist lookup and re-run the adversarial cases. They must now fail. Then grep the exec path for a shell option set true. Grep also for any API that takes one command line string. Grep for template literals inside a command or argument. Confirm zero hits. Then send an identifier such as `app && id` from the client. Confirm the spawn spy records no invocation at all.

from a real incident, 2026-07-07 · stack Node service · web UI launching local processes · verified by shell usage and input provenance in the exec path · command-injectionallowlistprocess-spawnleast-privilege

← every Field Kit