What a config can do to the machine that runs it
A shotlist.config.yaml is a file in a repository, and whether that is fine depends on whose repository. Some limits hold in every mode and cannot be turned off; the rest are what --untrusted takes away.
A run stays on its own site
site.url decides what a run may open: that host and everything under it. With site.url: https://example.com/, a recipe may shoot example.com/docs and api.example.com, and is refused google.com. A www. host covers its apex too. There is no flag to set — wandering off the site is a mistake far more often than an intention.
site:
url: https://example.com/
allow: [accounts.google.com] # a sign-in the flow passes through Files that are never read or written
Whatever the mode, shotlist will not read or write a path that goes through any of these — on disk, or in the path of a URL.
.env, .env.anything | .git |
.ssh, .gnupg, .aws | .npmrc, .netrc, .htpasswd |
credentials | id_rsa, id_ed25519, … (and .pub) |
*.pem, *.key, *.p12, *.pfx | *.keystore, *.jks |
This is not about trust: a config you wrote has no reason to read your keys either, and a typo in an install destination should not be able to write into .git. There is no flag to turn it off.
Putting a path out of bounds
To put a file or folder out of bounds for the whole project, list it under deny in the config. shotlist will not read, write or open a path that goes through it.
deny:
- fake-secret # a folder, and everything under it
- '*.sqlite' # a file Someone who later writes a recipe that reaches for it receives an error:
recipe "reports": `url`: "fake-secret" is a forbidden path — contact the administrator Each entry matches one segment of a path, with * standing for any run of characters — so a folder name stops everything under it, and *.sqlite stops a file wherever it sits. It is checked against filesystem paths and URL paths alike.
Three places can set it, and they are not equivalent. deny: in the config is the project's own declaration — visible to everyone, and editable by anyone who can edit the config. --deny belongs to whoever runs the command. SHOTLIST_DENY — a list separated by commas or colons — belongs to whoever set the machine or the CI image up, and is the one a recipe author cannot edit their way out of. All three add up, and none of them can subtract.
--untrusted, for a config you did not write
Run with --untrusted when the config did not come from someone you trust. In automation it often has not: a pull request from a fork can edit the config, and the runner it lands on has your credentials, your network and other people's work on it. A hosted service that shoots what its users submit has the same problem.
npx shotlist --check --untrusted # or SHOTLIST_UNTRUSTED=1 | default | --untrusted | |
|---|---|---|
site.serve starts a process | yes | refused |
opens file: and data: URLs | yes | refused |
| opens localhost, 10/8, 192.168, 169.254, cloud metadata | yes | refused |
| reads or writes outside the project | yes | refused |
site.allow widens the scope | yes | ignored |
That last row is the point of the flag: site.allow is a claim by the thing you are distrusting. What the operator typed still counts, and outlives it.
npx shotlist --check --untrusted --allow docs.example.com --allow-path /srv/shared Both are repeatable. Neither can be set from the config, because a control the config can switch off is not a control.
True in every mode
A config cannot run code. There is no eval: step and no way to reach one; queries travel into the page as data, never as source, so a malformed selector is a SyntaxError rather than an execution. site.serve runs a program directly with no shell. And a run cannot be made to hang: site.timeout bounds how long a query may take, viewport and scale are held to what a browser can paint, and retries and repeat are capped.
What this does not cover
The host check reads the name out of the URL. It cannot see that a name you allowed resolves to an address it would have refused, so a determined DNS rebind gets through: it is a fence, not a boundary. If you shoot what strangers submit, put the runner where it cannot reach anything it should not, and rate-limit it — a run is one browser and amplifies nothing, but an open one is still traffic you are sending.
shotlist has no database and issues no SQL, so there is nothing in it to inject into. It does have a browser. A recipe is a script that types into whatever it is pointed at, so a hosted instance that lets a stranger choose both the target and the keystrokes is a proxy for anything a browser can do to that target — SQL injection through somebody else's form included. The site scope is what keeps a submitted recipe aimed at the site it came with.