Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)

Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 21:03:05 +02:00
co-authored by Claude Opus 4.8
parent a1519f6e89
commit 19a7d48feb
13 changed files with 883 additions and 44 deletions
+25
View File
@@ -28,6 +28,14 @@ type Config struct {
// log in to the panel to configure it.
PocketBaseAdminEmail string
PocketBaseAdminPassword string
// OCPP control endpoint (Anker Solix charger control). OCPPRequireTLS rejects
// charger connections that did not arrive over TLS (a plaintext ws:// carries
// the charger's Basic-auth token in the clear); disable only for local dev.
// OCPPPublicURL, when set, is the canonical ws(s):// base an operator points
// the charger at, instead of deriving it from request headers.
OCPPRequireTLS bool
OCPPPublicURL string
}
// EnvFile is the .env path (relative to the working directory) that Load reads
@@ -53,6 +61,23 @@ func Load() Config {
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"),
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
}
}
// boolEnv reads a boolean environment variable, accepting the common truthy and
// falsey spellings and falling back to def when unset or unrecognized.
func boolEnv(key string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return def
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}