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:
co-authored by
Claude Opus 4.8
parent
a1519f6e89
commit
19a7d48feb
@@ -61,3 +61,28 @@ func TestOCPPRouteAndControlAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOCPPRequireTLS confirms a plaintext charger connection is rejected when TLS
|
||||
// is required, and that a TLS-terminating proxy (X-Forwarded-Proto) satisfies the
|
||||
// check (falling through to the auth gate).
|
||||
func TestOCPPRequireTLS(t *testing.T) {
|
||||
s := New(config.Config{UsersCollection: "users", OCPPRequireTLS: true}, pb.New("", "", ""))
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
// Plaintext ws:// with no forwarded-proto → rejected before auth (403).
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("plaintext connect under require-TLS: want HTTP 403, got %v", err)
|
||||
}
|
||||
|
||||
// Behind a TLS-terminating proxy the connection is accepted past the TLS gate
|
||||
// and then fails auth (401), proving the gate ordering.
|
||||
h := http.Header{}
|
||||
h.Set("X-Forwarded-Proto", "https")
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("forwarded-https connect: want HTTP 401 (past TLS gate), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user