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:
1 parent
a1519f6e89
commit
19a7d48feb
13 files changed
+883
-44
No files matched your search
@@ -1,9 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
func TestNormalizeControlMode(t *testing.T) {
|
||||
@@ -23,14 +27,20 @@ func TestNormalizeControlMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// binding builds a hashed binding the way handleAnkerControlToken does, so the
|
||||
// index tests exercise the real hash-at-rest path.
|
||||
func binding(token string) ankerChargerBinding {
|
||||
return ankerChargerBinding{TokenHash: hashToken(token), TokenHint: tokenHint(token)}
|
||||
}
|
||||
|
||||
func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-old"}})
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-old")})
|
||||
if _, ok := ci.lookup("tok-old"); !ok {
|
||||
t.Fatal("tok-old should resolve after first set")
|
||||
}
|
||||
// Regenerate: same charger, new token. The old token must stop resolving.
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-new"}})
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-new")})
|
||||
if _, ok := ci.lookup("tok-old"); ok {
|
||||
t.Error("tok-old should be invalidated after regeneration")
|
||||
}
|
||||
@@ -40,27 +50,136 @@ func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIndexRevoke(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok")})
|
||||
// Revoke = setUser with the charger removed.
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{})
|
||||
if _, ok := ci.lookup("tok"); ok {
|
||||
t.Error("token should not resolve after revocation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIndexIsolatesUsers(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a"}})
|
||||
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": {Token: "b"}})
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a")})
|
||||
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": binding("b")})
|
||||
// Re-setting u1 must not touch u2's token.
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a2"}})
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a2")})
|
||||
if _, ok := ci.lookup("b"); !ok {
|
||||
t.Error("u2's token should be untouched when u1 is updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIndexStoresNoPlaintext(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("super-secret-token")})
|
||||
// The plaintext must never appear as an index key.
|
||||
ci.mu.RLock()
|
||||
defer ci.mu.RUnlock()
|
||||
if _, ok := ci.byHash["super-secret-token"]; ok {
|
||||
t.Fatal("index must key on the hash, never the plaintext token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnkerBindingFor(t *testing.T) {
|
||||
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"token":"xyz"}}}}`)
|
||||
if got := ankerBindingFor(raw, "SN1").Token; got != "xyz" {
|
||||
t.Errorf("binding token = %q, want xyz", got)
|
||||
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"tokenHash":"abc","tokenHint":"1234"}}}}`)
|
||||
if got := ankerBindingFor(raw, "SN1").TokenHash; got != "abc" {
|
||||
t.Errorf("binding hash = %q, want abc", got)
|
||||
}
|
||||
if got := ankerBindingFor(raw, "other").Token; got != "" {
|
||||
t.Errorf("unknown charger should have empty token, got %q", got)
|
||||
if got := ankerBindingFor(raw, "other").TokenHash; got != "" {
|
||||
t.Errorf("unknown charger should have empty hash, got %q", got)
|
||||
}
|
||||
if got := ankerBindingFor(nil, "SN1").Token; got != "" {
|
||||
t.Errorf("nil settings should have empty token, got %q", got)
|
||||
if got := ankerBindingFor(nil, "SN1").TokenHash; got != "" {
|
||||
t.Errorf("nil settings should have empty hash, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAndHint(t *testing.T) {
|
||||
tok := "0123456789abcdef"
|
||||
if h := hashToken(tok); len(h) != 64 || h == tok {
|
||||
t.Errorf("hashToken should be a 64-char hex digest distinct from input, got %q", h)
|
||||
}
|
||||
if hashToken("a") == hashToken("b") {
|
||||
t.Error("distinct tokens must hash differently")
|
||||
}
|
||||
if got := tokenHint(tok); got != "cdef" {
|
||||
t.Errorf("tokenHint = %q, want cdef", got)
|
||||
}
|
||||
if got := tokenHint("ab"); got != "ab" {
|
||||
t.Errorf("short-token hint = %q, want ab", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDestructiveAction(t *testing.T) {
|
||||
for _, a := range []string{"reset", "unlock"} {
|
||||
if !isDestructiveAction(a) {
|
||||
t.Errorf("%q should be destructive", a)
|
||||
}
|
||||
}
|
||||
for _, a := range []string{"start", "stop", "limit", "clear-limit", "availability", "trigger", "config"} {
|
||||
if isDestructiveAction(a) {
|
||||
t.Errorf("%q should not be destructive", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
rl := newRateLimiter(2, time.Minute)
|
||||
if !rl.allow("k") || !rl.allow("k") {
|
||||
t.Fatal("first two calls should be allowed")
|
||||
}
|
||||
if rl.allow("k") {
|
||||
t.Error("third call in the window should be blocked")
|
||||
}
|
||||
if !rl.allow("other") {
|
||||
t.Error("a different key must have its own budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReauthenticateGuards(t *testing.T) {
|
||||
// With PocketBase unconfigured, re-auth must fail closed regardless of input,
|
||||
// and never treat a missing password/email as success.
|
||||
s := &Server{pb: pb.New("", "", "")}
|
||||
ctx := context.Background()
|
||||
cases := []struct {
|
||||
name string
|
||||
who *callerIdentity
|
||||
pass string
|
||||
}{
|
||||
{"nil caller", nil, "pw"},
|
||||
{"empty email", &callerIdentity{ID: "u1"}, "pw"},
|
||||
{"empty password", &callerIdentity{ID: "u1", Email: "a@b.c"}, ""},
|
||||
{"pb unconfigured", &callerIdentity{ID: "u1", Email: "a@b.c"}, "pw"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if s.reauthenticate(ctx, c.who, c.pass) {
|
||||
t.Errorf("%s: reauthenticate should fail closed", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnkerUpstreamAllowed(t *testing.T) {
|
||||
ok := []string{
|
||||
"wss://ankerpower-api-eu.anker.com/ocpp/CP1",
|
||||
"wss://ankerpower-api.anker.com/x",
|
||||
"ws://anker.com/y",
|
||||
}
|
||||
bad := []string{
|
||||
"wss://evil.example.com/ocpp/CP1",
|
||||
"wss://anker.com.evil.net/x",
|
||||
"not a url at all ::::",
|
||||
"",
|
||||
}
|
||||
for _, u := range ok {
|
||||
if !ankerUpstreamAllowed(u) {
|
||||
t.Errorf("%q should be allowed", u)
|
||||
}
|
||||
}
|
||||
for _, u := range bad {
|
||||
if ankerUpstreamAllowed(u) {
|
||||
t.Errorf("%q should be rejected", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user