Skip to content

Security hardening: constant-time auth, limit clamping, handshake gate, SSRF guard, keychain token, wss - #213

Merged
JulianKahnert merged 2 commits into
developfrom
feature/security-hardening
Oct 4, 2026
Merged

JulianKahnert merged 2 commits into
developfrom
feature/security-hardening

Conversation

@JulianKahnert

@JulianKahnert JulianKahnert commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes for the findings of a full-codebase security review that can be addressed in code. Nothing here changes the running deployment on its own; see "Not in this PR" for the parts that need operator action.

# Finding Fix
1 Bearer token compared with == (timing side-channel on a publicly tunnelled endpoint) TokenAuthenticationMiddleware.constantTimeEquals – XOR-accumulate over the longer input, length folded in
2 limit query params passed unclamped to Fluent (limit=2000000000 loads whole tables, limit=0 is invalid SQL); the OpenAPI generator does not enforce minimum/maximum OpenAPIController.clampedLimit (1…1000, default 100) used by /entities/history, /config/automations/{name}/runs, /config/automations/runs/recent
3 call/reply frames executed before the peer's hello, bypassing the protocol-version gate StarActorSystem.receive closes the connection on any non-hello frame before a valid hello; the real client/server flow is unaffected because both sides only send calls after .up
4 HealthCheck fetched any config-supplied URL from inside the container and logged the body (SSRF into db, cloudflared, metadata endpoints) HealthCheck.validate: https only, public hosts only (rejects loopback/private/link-local/CGNAT IPv4, loopback/ULA/link-local IPv6, localhost, .local/.internal/.localhost, single-label docker names); only the status code is logged. Known limit: the check is on the host string, so a public DNS name that resolves to an internal address still passes
5 MySQL TLS certificateVerification = .none hard-coded, password defaulted to vapor_password DATABASE_TLS_VERIFY=true enables full verification (warning logged while off); release builds refuse to start without DATABASE_PASSWORD
6 POST /config?skipValidation=true accepted configs with unknown entities silently Behaviour unchanged (the adapter is still bypassed), but the server now logs a warning that the referenced entities were not validated
7 Adapter kept the server token in UserDefaults (plaintext plist, in backups) AdapterCredentialStore stores it in the keychain, migrates existing installs on first launch and deletes the plaintext copy; KeychainHelper write functions now return success
8 Adapter could only dial ws://, so token + HomeKit traffic cross the LAN unencrypted ServerAddress.useTLS + "Use TLS (wss://)" toggle in the adapter settings; stored raw values (host###port) and the 8888→8080 migration keep working, TLS values are stored as host###port###tls

Docs: docs/setup-server.md gets an environment-variable table, docs/setup-FlowKitAdapter.md a "Server connection" section, CLAUDE.md the security invariants.

Behaviour changes to be aware of

  • Release builds now require DATABASE_PASSWORD. Both compose files already set it, so no action is needed, but a deployment relying on the implicit default would stop at startup with a clear message.
  • The adapter settings screen now pre-fills host/port from the stored address instead of localhost:8080.
  • A HealthCheck pointing at an http:// or internal URL now fails at execution with a descriptive error instead of running. The current config (https://hc-ping.com/…) passes.

Not in this PR

  • Secrets in HomeAutomation-config: moved to a git-ignored .env in JulianKahnert/HomeAutomation-config#17 (merged). The values that were committed stay in git history, so rotating at least AUTH_TOKEN is still recommended.
  • The adapter WebSocket shares the single API token and is reachable through the public tunnel (latest-connection-wins). A separate adapter credential and/or excluding /adapter/v1 from the public hostname is a follow-up design change.

Test plan

  • CI: swift build + swift test (new tests: SecurityHardeningTests, HandshakeGateTests, ServerAddressTests, HealthCheckURLValidationTests)
  • CI: FlowKitAdapter / FlowKitController builds, Docker build
  • Manually: FlowKit Adapter on the Mac mini – after first launch the token is in the keychain (ServerAuthToken gone from defaults read de.juliankahnert.HomeKitAdapterApp) and the link comes up
  • Manually: swift run from HomeAutomation-config still deploys the config; with skipEntityValidation = true the server log shows the warning

🤖 Generated with Claude Code

https://claude.ai/code/session_01QpC3PnuBWmyrYM3xpoSFr6

JulianKahnert and others added 2 commits October 4, 2026 09:51
Findings from a security review of the whole codebase that can be fixed in
code without touching the deployment:

- Compare the bearer token in constant time instead of with `==`, which
  short-circuits on the first differing byte and leaks a timing signal on a
  publicly reachable endpoint.
- Clamp every `limit` query parameter to the bounds declared in openapi.yaml;
  the generator parses but does not validate them, so a huge or negative
  limit could load whole tables into memory or produce invalid SQL.
- Reject `call`/`reply` frames on the StarActorSystem link until the peer's
  `hello` arrived, closing the connection instead of executing them. The
  hello is the only protocol-version gate and documented as the first frame.
- Only allow public https URLs for HealthCheck automations and stop logging
  the response body, so POST /config can no longer be used to reach internal
  services (db, cloudflared, metadata endpoints) from inside the container.
- Make MySQL TLS certificate verification configurable via
  DATABASE_TLS_VERIFY (logged warning while off) and refuse to start release
  builds without DATABASE_PASSWORD instead of falling back to the well-known
  default.
- Keep `skipValidation` on POST /config usable (also while the adapter is
  offline) but log every missing entity as a warning instead of accepting
  the config silently.
- Store the adapter's server token in the keychain (AdapterCredentialStore)
  instead of UserDefaults, migrating existing installs and deleting the
  plaintext copy; KeychainHelper now reports write failures.
- Let ServerAddress carry a TLS flag so the adapter can dial wss:// and the
  token never crosses the network in clear text. Stored raw values and the
  8888 port migration keep working.

Tests cover the constant-time compare, limit clamping, the handshake gate,
ServerAddress parsing/encoding and the HealthCheck URL rules. Docs list the
environment variables and the new adapter settings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpC3PnuBWmyrYM3xpoSFr6
POST /config?skipValidation=true used to bypass the adapter entirely; the
previous commit still called `homeManager.getAllEntitiesLive()` to log the
missing entities, which force-unwraps the HomeManager storage and crashed
AuthenticationIntegrationTests (no HomeManager configured there). Restore
the original control flow and keep only the warning that validation was
skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpC3PnuBWmyrYM3xpoSFr6
@JulianKahnert
JulianKahnert enabled auto-merge (squash) October 4, 2026 10:10
@JulianKahnert
JulianKahnert merged commit ea1e397 into develop Oct 4, 2026
4 checks passed
@JulianKahnert
JulianKahnert deleted the feature/security-hardening branch October 4, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant