2 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5.5 fc7f0cc1fd chore: file T-1 — per-device token for the gateway
Test, Build and Push / test-gateway (push) Successful in 14s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The first ticket on this board: /ws/voice and /devices accept any
client on the LAN, which reaches Tatlock and its Home Assistant
controls. Found in the workspace security sweep of 2026-09-23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:45:19 +02:00
jpmschweitzerandClaude f36f0fe431 fix(permissions): narrow rm -rf deny globs to their exact forms
Test, Build and Push / test-gateway (push) Successful in 13s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The trailing wildcard on the three rm -rf deny entries spanned path
separators, so Bash(rm -rf /*) matched every absolute path on the
machine rather than the filesystem root, and the ~ and $HOME entries
had the same shape. Narrowed to the exact literal forms.

These rules match literal command text, so they still stop a typo on
rm -rf /, rm -rf ~ or rm -rf $HOME exactly, but they no longer stop a
recursive delete aimed at any other path. That reduced cover is
deliberate, not an oversight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:33:17 +02:00
4 changed files with 43 additions and 3 deletions
+3 -3
View File
@@ -58,9 +58,9 @@
"Bash(git reset --hard*)", "Bash(git reset --hard*)",
"Bash(git restore .*)", "Bash(git restore .*)",
"Bash(mkfs*)", "Bash(mkfs*)",
"Bash(rm -rf $HOME*)", "Bash(rm -rf $HOME)",
"Bash(rm -rf /*)", "Bash(rm -rf /)",
"Bash(rm -rf ~*)", "Bash(rm -rf ~)",
"Bash(su *)", "Bash(su *)",
"Bash(sudo *)", "Bash(sudo *)",
"Bash(toj)", "Bash(toj)",
+19
View File
@@ -0,0 +1,19 @@
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'description', NULL, 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', NULL, '2026-09-23 11:45:03', '2026-09-23 11:45:03.865', '2026-09-23 11:45:03.865', NULL, '93027bc356d0bbc60ae0e38b9afdeb88', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -0,0 +1 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'T-1', '2026-09-23 11:45:03.721', '2026-09-23 11:45:03.721', NULL, '4549dc91ac440f4b47a95e9072256904', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+20
View File
@@ -0,0 +1,20 @@
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.720', NULL, '7ef01f11d6709f6e094634f5aacc6789', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.865', NULL, 'de24fb675828ba55b4fdd3defd406cdb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;