Commit Graph
2 Commits
Author SHA1 Message Date
Hermes 99ec6ebc53 fix(tailscale-admin): harden apiToken/tags/description validation (DC-080) [glm-grade=B]
DC-080 round-1 GLM-5.3 judge verdict: B. Round-2 polish folded into same
commit per multi-round fix-first protocol: tighten tag regex to require
non-empty name after 'tag:' (matches Tailscale spec), drop dead
`module.exports.createApp = null` line.

THREAT MODEL
Pre-fix, /api/v1/tailscale/* and /api/v1/tailscale/admin/* (TOTP-gated)
had inconsistent checks on caller-supplied input. Three coupled gaps:

  (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but
      had NO length cap — body-parser limit was the only ceiling. A 1 MB
      string starting with `tskey-api-` would be `.trim()`-ed, sent to
      Tailscale's /devices endpoint, and waste server-side CPU on a
      request that will always 401.
  (b) POST /settings/test accepted `apiToken` from the body with NO
      validation at all. The PUT route's prefix check did NOT extend to
      this path. An operator could submit arbitrary junk and the
      container would still call /devices on the Tailscale API with it
      (DoS-reflection + fingerprint timing for an attacker probing
      whether this API token format is accepted).
  (c) POST /admin/keys validated `tags` as Array but NOT per-element
      type — `tags: ['tag:guest', null, 123, {injection: true}]` would
      be forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
      and would 400 the request, but the bad shape reached the wire.
      Similarly `description` had no length cap (Tailscale caps at 120
      chars per their docs).

All three are gated by TOTP — this is a logged-in-operator / phished-
session threat surface, not anonymous-unauth. The fix is defense-in-
depth: a bug in the auth path (TOTP bypass, session theft, future route
handler trust-boundary drift) should not turn these endpoints into a
`submit anything and forward to Tailscale` relay.

FIX 1 — Shared validators (round-1)
- `_validateApiToken(token)`: typeof string check, prefix required,
  length cap 256 chars. Catches empty/null/non-string AND oversize.
- `_validateTags(tags)`: undefined/null allowed (optional field),
  Array.isArray check, max 32 entries, per-element string check,
  per-element length cap 64 chars, regex
  `/^tag:[a-z0-9][a-z0-9_-]{0,62}$/` (round-2: requires non-empty
  name after `tag:` per Tailscale spec).
- `_validateDescription(description)`: undefined/null allowed, string
  type check, length cap 120 chars (matches Tailscale's documented cap).

All three return null on success or an error string on failure. Route
  layer maps to 400 via `errorResponse`. Validators exported via
  `module.exports._validators` for direct unit testing (otherwise
  unreachable from outside the factory closure).

FIX 2 — Endpoint wiring (round-1)
- PUT /settings: replaced inline `!startsWith('tskey-api-')` check with
  `_validateApiToken(token)`. Single source of truth for the rule.
- POST /settings/test: added `_validateApiToken(token)` guard BEFORE
  calling `client.setApiToken(token)`. The body is optional, so the
  guard is skipped when no token is provided (uses stored token path).
- POST /admin/keys: replaced `Array.isArray(opts.tags)` shallow check
  with `_validateTags(opts.tags)`, plus `_validateDescription(opts.description)`.
  Old code already validated `expirySeconds`; that stays.

FIX 3 — Round-2 polish
- TAG_KEY_RE: `/^[a-z0-9][a-z0-9:_-]{0,63}$/` → `/^tag:[a-z0-9][a-z0-9_-]{0,62}$/`.
  The old regex accepted `tag:` (empty name), which Tailscale's API
  rejects. New regex requires `tag:` prefix and ≥1 alphanumeric name
  char followed by [a-z0-9_-]{0,62} — total length up to 67 chars, well
  within Tailscale's documented 15..63 char tag length.
- Removed `module.exports.createApp = null` vestigial line — the file
  only exports the factory function and the _validators bag.

TESTS (29 original + 16 new = 45 in this suite)
- 4 PUT /settings new: length cap, non-string type, prefix round-trip
  (existing 'starts with' tests already passed), plus the original
  6 (4 pre-existing PUT tests stay green).
- 4 POST /settings/test new: prefix rejection, length cap, stored-token
  path with empty body still works.
- 4 POST /admin/keys new: null/123/object entries rejected, uppercase /
  whitespace / CRLF rejected, description length cap, canonical
  lowercase `tag:server` accepted.
- 4 direct validator unit tests: validateApiToken (5 cases incl. cap-edge),
  validateTags (8 cases incl. round-2 bare-'tag:' rejection), validateDescription
  (3 cases incl. cap-edge), constants-export surface.

All 45 tests pass on DNS2 (verified). Full repo suite unchanged: 2351/2351.
2026-08-18 18:32:34 -07:00
Krystie 6fb4f9b169 DC-043: tailscale coordination API client + admin/settings routes
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).

src/managers/tailscale-coord.js — new module:
  * Ping, list/get/delete devices, create/list/delete pre-auth keys,
    list users, get/update ACL
  * 5min read cache, 60s device-list cache, 1hr ping cache
  * Cache invalidation on writes
  * Graceful {configured:false} when no token
  * TailscaleCoordError class with code mapping (unauthorized, not_found,
    rate_limited, server_error)
  * fetchImpl injection point for tests; native https in production
  * 45 unit tests covering all paths

src/context/index.js — new tailscaleCoord namespace:
  * getClient() lazy-builds a fresh client each call (token re-read from
    credentialManager so settings changes take effect without restart)
  * loadMetadata/saveMetadata for tailscale-config.json
  * setApiToken/hasApiToken wrappers around credentialManager

routes/tailscale-admin.js — new routes:
  * GET    /api/v1/tailscale/settings         — config status, never the token
  * PUT    /api/v1/tailscale/settings         — validate + store encrypted
  * DELETE /api/v1/tailscale/settings         — wipe token + metadata
  * POST   /api/v1/tailscale/settings/test    — ping without saving
  * GET    /api/v1/tailscale/admin/devices    — full device list
  * DELETE /api/v1/tailscale/admin/devices/:id — revoke device
  * GET    /api/v1/tailscale/admin/users      — tailnet users
  * GET    /api/v1/tailscale/admin/keys       — pre-auth key metadata
  * POST   /api/v1/tailscale/admin/keys       — create pre-auth key (returns secret ONCE)
  * DELETE /api/v1/tailscale/admin/keys/:id   — revoke pre-auth key
  * 29 route integration tests with supertest

src/app.js — wired the new router into the /api/v1/tailscale mount.

BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
            Note: deliberately no token auto-rotation — Tailscale API keys
            don't auto-renew, and silently re-issuing admin credentials
            would erode the audit-trail checkpoint that token expiry
            provides.

Tests: 1167 -> 1212 (+45), all green. Lint clean.
2026-07-06 21:28:03 -07:00