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>
216 lines
6.5 KiB
Go
216 lines
6.5 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/pb"
|
|
)
|
|
|
|
func TestNormalizeControlMode(t *testing.T) {
|
|
cases := map[string]string{
|
|
"off": "off",
|
|
"own": "own",
|
|
"proxy": "proxy",
|
|
"OWN": "own",
|
|
" Proxy": "proxy",
|
|
"": "", // unset — cascade continues to the next layer
|
|
"bogus": "", // unknown — treated as unset
|
|
}
|
|
for in, want := range cases {
|
|
if got := normalizeControlMode(in); got != want {
|
|
t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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": 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": binding("tok-new")})
|
|
if _, ok := ci.lookup("tok-old"); ok {
|
|
t.Error("tok-old should be invalidated after regeneration")
|
|
}
|
|
b, ok := ci.lookup("tok-new")
|
|
if !ok || b.UserID != "u1" || b.Serial != "SN1" {
|
|
t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok)
|
|
}
|
|
}
|
|
|
|
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": 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": 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":{"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").TokenHash; got != "" {
|
|
t.Errorf("unknown charger should have empty hash, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractWSURL(t *testing.T) {
|
|
cases := map[string]string{
|
|
`{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1",
|
|
`{"a":{"b":[{"endpoint":"ws://x/y"}]}}`: "ws://x/y",
|
|
`{"data":{"note":"https://not-a-ws-url"}}`: "",
|
|
`{"empty":true}`: "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := extractWSURL(json.RawMessage(in)); got != want {
|
|
t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOCPPEndpoint(t *testing.T) {
|
|
s := &Server{}
|
|
// Plain HTTP request → ws://
|
|
r := httptest.NewRequest("GET", "http://host.example/x", nil)
|
|
r.Host = "host.example"
|
|
if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" {
|
|
t.Errorf("endpoint = %q", got)
|
|
}
|
|
// Behind a TLS-terminating proxy → wss://
|
|
r2 := httptest.NewRequest("GET", "http://host.example/x", nil)
|
|
r2.Host = "host.example"
|
|
r2.Header.Set("X-Forwarded-Proto", "https")
|
|
if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" {
|
|
t.Errorf("tls endpoint = %q", got)
|
|
}
|
|
}
|