Stop the one-line install handing a standard user a way in

The bootstrap command staged its script into C:\Windows\Temp and then ran it
elevated. That directory grants Users create-file with CREATOR OWNER full
control, so somebody without admin rights can pre-plant the exact path the
elevated run is about to execute. It now stages under Program Files, creates the
directory with an explicit DACL when it is missing, and refuses to run a staged
file that is not owned by an admin principal. A failed hardening is loud rather
than silent: the run stops instead of carrying on with a file it could not
protect.

The token moved out of the query string into a header. In a query string it
lands verbatim in the reverse proxy and IIS logs, which is a credential written
to a file nobody thinks of as a secret store. The query form is still accepted,
because bays provisioned before this change are holding install commands that
use it, and it logs that it was used - without logging the token - so a site can
see who still needs a re-issued command before turning it off with
plugin.endpoints.bootstrapquerytoken.

Two smaller things this also closes:

A request with no token at all answered 400 naming the header, which told an
anonymous caller the state of that switch and contradicted the module's own
promise that anything unusable answers 404. No token is now 404. The 400 is kept
for a token genuinely presented in the query form while the query form is off,
which is a caller who needs to know their command is stale.

The rollout documentation was republishing the vulnerable one-liner verbatim, so
a tech following the runbook reinstated exactly what this fixes. Both documents
now carry the current command.

Also corrects the operator-facing copy for the token, which named a query
argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token,
per bootstrap.py's own constants. Anybody following the old text got a 404 with
no reason given.

Both switches added in this range are now declared with the other plugin
settings and reachable from their settings page. Read through Setting.get but
never declared, they were rows that did not exist until someone wrote one by
hand, which on an air-gapped box means the feature was unreachable.
This commit is contained in:
cproudlock
2026-09-18 13:30:08 -04:00
parent 3f80425f30
commit 987f5c0d0f
7 changed files with 934 additions and 81 deletions

View File

@@ -791,27 +791,62 @@ One-line install and hands a technician one command, which is run in an
ELEVATED PowerShell on a PC with no agent:
```
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
```
Download then run, never `irm ... | iex`: Microsoft Defender scores the
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
every bay runs Defender. The shape of the line is load-bearing too:
every bay runs Defender. The rest of the shape is load-bearing too:
- a literal temp path and no `$` anywhere, because pasting into an
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
PARENT and hand the child a broken line - as written it means the same in
cmd.exe and in PowerShell;
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
lands verbatim in the IIS and waitress access logs, and a live install token
has no business sitting in a log file;
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
OWNER full control of what they create, so any standard user on the bay can
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
paste the command, which then runs the planted content elevated;
- the line re-asserts an admin-only DACL on the directory it creates
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
`S-1-5-18`, never group names, which a rename or a localized install would
break), and CHECKS that icacls succeeded: it is a native command, so it
reports failure only in an exit code and `$ErrorActionPreference` never
applies to it. Exit 6 with a red message, rather than downloading into a
directory that still inherits whatever the parent grants;
- it refuses (exit 5) to run a staged file owned by anyone but an
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
file looks like;
- a literal staging path and no `$` anywhere - including the exit-code read,
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
already-running PowerShell would otherwise expand every `$` in the PARENT and
hand the child a broken line. As written it means the same in cmd.exe and in
PowerShell;
- single quotes for every inner literal, so the outer `-c "..."` is never
closed early;
- `try`/`finally`, so the dropped file is removed even when the script exits
non-zero or the download itself fails. The wrapper's own exit code is the
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
would rather save the file and run it. Fetching it does not spend a use either
way.
The same script is offered in the UI as a **Download .ps1** link, for a tech
who would rather save the file and run it. That link is the one caller that
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
does not spend a use either way.
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
before this change - one still running a command pasted when the query form was
the published shape - keeps working. It is deprecated: every such fetch logs a
warning naming the caller's IP, and that warning is how the rollout finds bays
whose command needs re-issuing. Do not publish it, do not paste it into a new
bay, and do not build it by hand. A site that has re-issued all of its live
commands turns the query form off with
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
Download .ps1 link.
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
site agent > One-line install ("Accept the token in the URL"). It is read
through the core settings API, not a route of this plugin's own.
The token is only an authorization to FETCH the installer. The package it
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
@@ -822,26 +857,41 @@ Admin routes (admin role, audited):
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
(`hours` 1-168, `maxuses` 1-500). Returns
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
URL the command downloads, for the UI's Download .ps1 link.
The secret is in the response exactly once; only its sha256 and a 12-character
`tokenprefix` are stored.
`{"secret", "command", "scripturl", "token"}`. `command` carries the token in
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
the `?t=<secret>&via=download` form a browser link can actually use. The
`via=download` marker is what keeps an admin's own click out of the
un-migrated-bay warning count - it is a hint, not authorization, and nothing
is granted by it; what matters is that a command generated before this change
cannot carry it. The secret is in the response exactly once; only its sha256
and a 12-character `tokenprefix` are stored.
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
`usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state`
of `active`, `expired`, `exhausted` or `revoked`. Never a secret.
- `POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke`
Token-authenticated routes. No login; the token is the whole authorization and
travels in the query string as `t`, because a PC with no agent has neither a
credential nor a user session to put in a header:
Token-authenticated routes. No login; the token is the whole authorization,
because a PC with no agent has neither a credential nor a user session. It
travels in the `X-Endpoint-Bootstrap-Token` header:
| Route | Spends a use | Returns |
|---|---|---|
| `GET /api/endpoints/siteagent/bootstrap.ps1?t=...` | no | `text/plain` PowerShell, generated per token |
| `GET /api/endpoints/siteagent/bootstrap/package?t=...` | yes | the keyless agent package ZIP |
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
Both also accept `?t=<secret>` while
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
provisioned before the header form and for the UI Download .ps1 link. A query
token without `via=download` logs a deprecation warning against the caller's
IP; the Download link's own fetches log at info instead and are audited as
`(query-string token, admin download link)`, so the warning count stays a list
of bays to re-issue. With the setting false, a query token answers **400**
naming the header.
Both answer **404** for a token that is unknown, malformed, expired, revoked or
exhausted, so the URL confirms nothing about which tokens exist. Both are
exhausted, and for a request that presents no token at all - so the URL
confirms nothing about which tokens exist, and a bare probe cannot learn
whether this site still accepts the query form. Both are
rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in
`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over
budget) and both write an audit row against `EndpointBootstrapToken` carrying
@@ -851,7 +901,9 @@ retried paste must not burn the download budget.
The generated script:
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
administrator;
administrator (the wrapper line has already refused with exit 6 if the
staging directory could not be locked down, or exit 5 if the staged file was
owned by someone else);
- refuses when the agent service already exists (exit 2) and says a managed bay
should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set
to anything but `0` in the same session;