Security & Recovery — what protects the toolkit, and exactly what to do when you are locked out. Launcher v3.27.0
After 8 wrong passwords the API blocks that network. The block also rejects the correct password — deliberately, otherwise it would not slow an attacker down. Pick the first option that suits you.
The login screen shows a live countdown. The first lockout is only 60 seconds. It grows only if you keep failing: 1 min → 5 min → 15 min → 60 min.
Lockout counters live in the serverless function's memory, so a fresh deployment starts clean.
Equivalent from a terminal — either works:
npx vercel --prod
# or simply push any commit
git commit --allow-empty -m "clear lockout" && git push
Only works if you set ADMIN_RECOVERY_KEY beforehand — see
section 3. Send it once and the lock is released:
curl -H "x-recovery-key: <your-recovery-key>" https://toolkit.bistab.com.np/api/tree
PowerShell:
Invoke-RestMethod -Uri "https://toolkit.bistab.com.np/api/tree" `
-Headers @{ "x-recovery-key" = "<your-recovery-key>" }
Then sign in to /admin normally.
The lock is per public IP, so a phone hotspot or VPN gets you in. Useful as a last resort; prefer B or C.
There is no password reset email — the password is an environment variable. Change it:
ADMIN_PASSWORD → Edit.Generate a strong password:
node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"
Optional but recommended — it turns a lockout from "wait or redeploy" into a one-line fix.
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
ADMIN_RECOVERY_KEY, paste the value, select all environments, Save.ADMIN_PASSWORD anyway, so it grants nothing new.[guard] recovery key used from <ip>. Check there if you see one you did not trigger.| Setting | Value | Why |
|---|---|---|
| Failures before lockout | 8 | Room for typos, far below what guessing needs |
| Lockout length | 1 → 5 → 15 → 60 min | Escalates per repeat offence; caps at an hour |
| Reset | On any successful login | A fixed typo does not haunt you later |
| Scope | Per IP and per credential type | A bad launcher key cannot lock the admin panel |
| Requests / minute | 600 authenticated · 120 anonymous | A whole office behind one NAT is not throttled |
Lockouts are keyed by IP plus which credential was attempted. In practice:
MSP_KEY retrying the launcher cannot lock you out of /admin.Nothing sensitive ever reaches a client. The browser and the launcher talk only to the Vercel API, which holds the GitHub token server-side and proxies the repo.
Launcher / Admin browser
│ x-launcher-key or x-admin-key
▼
Vercel API (api/_guard.js gates every route)
│ Authorization: Bearer GITHUB_TOKEN ← never leaves the server
▼
Private GitHub repo (data/ subtree only)
| Variable | Purpose | Required |
|---|---|---|
GITHUB_TOKEN | Reads/writes the private repo. Server-side only. | Yes |
GITHUB_REPO | owner/repo | Yes |
GITHUB_REF | Branch (defaults to main) | No |
ADMIN_PASSWORD | Sign-in for /admin; required for all writes | Yes |
LAUNCHER_KEY | Password for the PowerShell launcher. If unset, read endpoints are open. | No |
ADMIN_RECOVERY_KEY | Break-glass lockout release (32+ chars) | No |
LAUNCHER_KEY is not set, anyone who knows the URL can read
data/. That is intentional (the launcher bootstraps anonymously), but set it if your
scripts are sensitive.| Area | Protection |
|---|---|
| Brute force | Per-IP rate limiting + escalating lockout on every route |
| Timing attacks | All secrets compared with crypto.timingSafeEqual over SHA-256 |
| Path traversal | One shared validator: rejects .., absolute paths, backslashes, NUL bytes, ://, and confines everything to data/ |
| Privilege escalation | Writes and deletes cannot touch anything outside data/ — including public/launcher.ps1 |
| CSRF | Auth uses a custom header, which cross-origin pages cannot send without a preflight the API refuses |
| Clickjacking | X-Frame-Options: DENY + frame-ancestors 'none' |
| Stored XSS | Uploaded HTML/XML forced to download; API responses carry default-src 'none'; sandbox |
| Supply chain | All 8 CDN assets pinned with SHA-384 Subresource Integrity |
| Secret leakage | Token never sent to clients; upstream errors logged server-side, not returned |
| Transport | HSTS (2 years, includeSubDomains) |
From the security review of the whole codebase. Kept here so the reasoning is not lost.
data/write.js and delete.js only checked for path traversal, never for the
data/ prefix that every read route enforced. A leaked or guessed admin password could
therefore overwrite public/launcher.ps1 — the script every managed endpoint executes via
irm | iex — turning admin access into remote code execution across the fleet.
FIXED Both routes now use the shared validator and are confined to data/.
/api/tree doubles as the login oracle (the admin UI validates the password against it),
so the single shared password could be guessed at full speed, unthrottled and unlogged.
FIXED Per-IP limiting plus escalating lockout on every route.
!== on secrets short-circuits. Now constant-time.Content-Disposition threw on names like
“02) Printer Setup – HP DesignJet T870.docx”. Now RFC 5987 encoded..env gitignored and untracked.The same protections cover the PowerShell launcher, because it authenticates server-side — not with a local password check that could be edited out.
console.ps1 retries /api/list and re-prompts while it gets 401.launcher.ps1 shows its login overlay on 401 from /api/tree.429 plainly — "too many attempts from this network" with the wait
time — instead of a misleading "cannot reach API".irm | iex); nothing is installed.Clear-Footprint sweeps stale ones.MSP_KEY environment variable; it is never
embedded in the served script..env and .env.* are gitignored; keep it that way.Set MSP_KEY on a client PC so the launcher signs in without prompting:
[Environment]::SetEnvironmentVariable('MSP_KEY','<launcher key>','User')
The protections have an automated suite. Run it after touching anything under api/:
node test/security.test.js # 30 checks — auth, escalation, traversal, headers
node test/lockout.test.js # 15 checks — backoff, recovery key, both login surfaces
node test/startup.test.js # 7 checks — admin loads its landing page
node test/relocate.test.js # 13 checks — rename/move/copy never hangs
All four should end with ALL n CHECKS PASSED. They use a mocked GitHub API, so they
are safe to run offline and touch nothing real.
Not code-fixable here — operational choices worth making:
ADMIN_PASSWORD to a long random string. It is the only
credential and is held in the browser's sessionStorage.GITHUB_TOKEN to a fine-grained PAT with Contents read/write on
this repo only, then rotate it.LAUNCHER_KEY if the scripts in data/ are sensitive.