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
@@ -383,6 +383,21 @@ const DESIRED = {
|
||||
F.select("permission", ["read", "write"], true),
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// Append-only audit trail for OCPP charger control (start/stop/limit/reset/
|
||||
// unlock/…, token generate/revoke, and charger connects). Actor/org are stored
|
||||
// as plain text ids (not relations) so the trail survives user or org deletion.
|
||||
// Written best-effort by the API Server (internal/api/integrations_ankersolix_control.go);
|
||||
// if this collection is absent, control still works and only the structured log
|
||||
// line remains.
|
||||
control_audit: [
|
||||
F.text("user_id"),
|
||||
F.text("org_id"),
|
||||
F.text("serial"),
|
||||
F.text("action", true),
|
||||
F.text("result"),
|
||||
F.json("params", 10000),
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// Tenants that users belong to. A superadmin spans all of them; an admin
|
||||
// manages only their own.
|
||||
organizations: [
|
||||
@@ -438,6 +453,11 @@ const INDEXES = {
|
||||
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
|
||||
// Read as "this car's checks, newest first" every time.
|
||||
technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
|
||||
// Audit is queried "this charger's events, newest first" and "this user's events".
|
||||
control_audit: [
|
||||
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
||||
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
||||
],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
@@ -466,6 +486,7 @@ async function main() {
|
||||
"maintenance_entries",
|
||||
"car_documents",
|
||||
"reminders",
|
||||
"control_audit",
|
||||
]) {
|
||||
if (collections.some((c) => c.name === name)) continue;
|
||||
await createCollection(token, name, DESIRED[name], format, idByName);
|
||||
@@ -490,6 +511,7 @@ async function main() {
|
||||
"maintenance_entries",
|
||||
"car_documents",
|
||||
"reminders",
|
||||
"control_audit",
|
||||
]) {
|
||||
await reconcileFields(token, name, DESIRED[name], format, idByName);
|
||||
}
|
||||
@@ -497,7 +519,7 @@ async function main() {
|
||||
console.log(
|
||||
"\nDone. Collections ready: organizations, users, cars, service_records,\n" +
|
||||
"technical_checks, parts, car_shares, fuel_entries, maintenance_entries,\n" +
|
||||
"car_documents, reminders.",
|
||||
"car_documents, reminders, control_audit.",
|
||||
);
|
||||
console.log(
|
||||
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
|
||||
|
||||
Reference in New Issue
Block a user