diff --git a/assets/js/components/Auth/AdminPasswordPrompt.vue b/assets/js/components/Auth/AdminPasswordPrompt.vue new file mode 100644 index 000000000..b2a689af2 --- /dev/null +++ b/assets/js/components/Auth/AdminPasswordPrompt.vue @@ -0,0 +1,27 @@ + + + {{ $t("config.adminPassword.title") }} + {{ $t("config.adminPassword.description") }} + + + + + diff --git a/assets/js/components/Config/DeviceModal/Actions.vue b/assets/js/components/Config/DeviceModal/Actions.vue index faddd5183..a5fe69235 100644 --- a/assets/js/components/Config/DeviceModal/Actions.vue +++ b/assets/js/components/Config/DeviceModal/Actions.vue @@ -1,5 +1,7 @@ + + + + + @@ -207,6 +214,7 @@ import AuthConnectButton from "../AuthConnectButton.vue"; import { initialTestState, performTest } from "../utils/test"; import { reportValidityInModal } from "../utils/reportValidityInModal"; import { initialAuthState, prepareAuthLogin } from "../utils/authProvider"; +import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue"; import sleep from "@/utils/sleep"; import { ConfigType } from "@/types/evcc"; import type { DeviceType, Timeout } from "@/types/evcc"; @@ -223,6 +231,7 @@ import { applyDefaultsFromTemplate, createDeviceUtils, fetchServiceValues, + ADMIN_PASSWORD_REQUIRED, } from "./index"; import deepEqual from "@/utils/deepEqual"; @@ -244,6 +253,7 @@ export default defineComponent({ YamlEntry, AuthCodeDisplay, AuthConnectButton, + AdminPasswordPrompt, }, props: { deviceType: { type: String as PropType, required: true }, @@ -313,6 +323,9 @@ export default defineComponent({ test: initialTestState(), serviceValues: {} as Record, serviceValuesTimer: null as Timeout | null, + adminPasswordValue: "", + adminPasswordRequired: false, + adminPasswordInvalid: false, }; }, computed: { @@ -546,10 +559,15 @@ export default defineComponent({ if (this.test.isError || this.test.isSuccess) { this.test = initialTestState(); } + this.adminPasswordRequired = false; + this.adminPasswordInvalid = false; this.updateServiceValues(); }, deep: true, }, + adminPasswordValue() { + this.adminPasswordInvalid = false; + }, authValues: { handler() { if (this.authRequired) { @@ -699,22 +717,35 @@ export default defineComponent({ this.saving = true; try { - const { name } = await this.device.create(this.apiData, force); + const res = await this.device.create(this.apiData, force, this.adminPasswordValue); + this.applyAdminPasswordState(res.status); + if (res.status === ADMIN_PASSWORD_REQUIRED) { + this.saving = false; + return; + } this.saving = false; this.succeeded = true; await sleep(500); - this.$emit("added", name); - await closeModal({ action: "added", name }); + this.$emit("added", res.data.name); + await closeModal({ action: "added", name: res.data.name }); } catch (e) { - handleError(e, "create failed"); this.saving = false; + handleError(e, "create failed"); } }, async testManually() { await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement); }, async testDevice() { - return this.device.test(this.id, this.apiData); + const res = await this.device.test(this.id, this.apiData, this.adminPasswordValue); + this.applyAdminPasswordState(res.status); + return res; + }, + // reveal the admin password field when required, flag it invalid if a password was already sent + applyAdminPasswordState(status: number) { + this.adminPasswordRequired = status === ADMIN_PASSWORD_REQUIRED; + this.adminPasswordInvalid = + status === ADMIN_PASSWORD_REQUIRED && !!this.adminPasswordValue; }, async update(force = false) { if (this.test.isUnknown && !force) { @@ -729,16 +760,26 @@ export default defineComponent({ } this.saving = true; try { - await this.device.update(this.id!, this.apiData, force); + const res = await this.device.update( + this.id!, + this.apiData, + force, + this.adminPasswordValue + ); + this.applyAdminPasswordState(res.status); + if (res.status === ADMIN_PASSWORD_REQUIRED) { + this.saving = false; + return; + } this.saving = false; this.succeeded = true; await sleep(500); this.$emit("updated"); await closeModal({ action: "updated" }); } catch (e) { + this.saving = false; console.error("update failed", e); handleError(e, "update failed"); - this.saving = false; } }, async remove() { @@ -752,6 +793,8 @@ export default defineComponent({ }, handleOpen() { this.isModalVisible = true; + this.adminPasswordRequired = false; + this.adminPasswordInvalid = false; }, handleClose() { this.$emit("close"); diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 3f4e16a4e..f57c05bf6 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -3,6 +3,16 @@ import { ConfigType } from "@/types/evcc"; import api from "@/api"; import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder"; +// config write needs the admin password (script plugin) +export const ADMIN_PASSWORD_REQUIRED = 428; + +const allowAdminPasswordRequired = (status: number) => + (status >= 200 && status < 300) || status === ADMIN_PASSWORD_REQUIRED; + +function adminPasswordHeader(adminPassword = ""): Record { + return adminPassword ? { "X-Admin-Password": adminPassword } : {}; +} + export type Product = { group: string; name: string; @@ -189,17 +199,25 @@ export const fetchServiceValues = async ( }; export function createDeviceUtils(deviceType: DeviceType) { - function test(id: number | undefined, data: any) { + function test(id: number | undefined, data: any, adminPassword = "") { let url = `config/test/${deviceType}`; if (id !== undefined) { url += `/merge/${id}`; } - return api.post(url, data); + const opts = { + headers: adminPasswordHeader(adminPassword), + validateStatus: allowAdminPasswordRequired, + }; + return api.post(url, data, opts); } - function update(id: number, data: any, force = false) { - const params = { force }; - return api.put(`config/devices/${deviceType}/${id}`, data, { params }); + function update(id: number, data: any, force = false, adminPassword = "") { + const opts = { + headers: adminPasswordHeader(adminPassword), + validateStatus: allowAdminPasswordRequired, + params: { force }, + }; + return api.put(`config/devices/${deviceType}/${id}`, data, opts); } function remove(id: number) { @@ -211,10 +229,13 @@ export function createDeviceUtils(deviceType: DeviceType) { return response.data; } - async function create(data: any, force = false) { - const params = { force }; - const response = await api.post(`config/devices/${deviceType}`, data, { params }); - return response.data; + function create(data: any, force = false, adminPassword = "") { + const opts = { + headers: adminPasswordHeader(adminPassword), + validateStatus: allowAdminPasswordRequired, + params: { force }, + }; + return api.post(`config/devices/${deviceType}`, data, opts); } async function loadProducts(lang?: string, usage?: string) { diff --git a/assets/js/components/Config/YamlEditor.vue b/assets/js/components/Config/YamlEditor.vue index 22b7f1560..53ca780a5 100644 --- a/assets/js/components/Config/YamlEditor.vue +++ b/assets/js/components/Config/YamlEditor.vue @@ -61,7 +61,6 @@ export default { diff --git a/assets/js/components/Config/utils/test.ts b/assets/js/components/Config/utils/test.ts index f6e8d6047..578fd3a21 100644 --- a/assets/js/components/Config/utils/test.ts +++ b/assets/js/components/Config/utils/test.ts @@ -1,5 +1,6 @@ import type { AxiosResponse } from "axios"; import sleep from "@/utils/sleep"; +import { ADMIN_PASSWORD_REQUIRED } from "../DeviceModal/index"; import { reportValidityInModal } from "./reportValidityInModal"; export type TestState = { @@ -36,6 +37,10 @@ export const performTest = async ( const startTime = Date.now(); try { const res = await api(); + if (res.status === ADMIN_PASSWORD_REQUIRED) { + state.isUnknown = true; // not testable until the admin password is provided + return false; + } state.isError = false; state.error = null; state.errorLine = null; diff --git a/docs/agents/api-security.md b/docs/agents/api-security.md index 519d577c6..ab3b51ca5 100644 --- a/docs/agents/api-security.md +++ b/docs/agents/api-security.md @@ -88,11 +88,21 @@ achieves the same effect (the previous key stops working immediately). | State / read-only / basic charging control | Public | | | Set or update admin password | Public | admin password | | Configuration | Secure | | +| Configuration embedding a script plugin | Critical | api key or admin password | | System: logs, cache, shutdown | Secure | | | API key status | Secure | | | System: backup / restore / reset | Critical | api key or admin password | | API key regenerate | Critical | admin password | +Device test, create, and update (`/api/config/test/{class}` and `/api/config/devices/{class}`) +instantiate a config immediately, so a `script` plugin in the payload runs a shell command on the +server. Because that command could read credentials a session is not otherwise allowed to see (for +example the contents of the database), these requests are treated as Critical when the config embeds +a script plugin, at any nesting depth. A session caller must supply the admin password in the +`X-Admin-Password` header; an API-key caller passes without it. The `go` (yaegi) and `js` (otto) +plugins are excluded: their interpreters are sandboxed to pure computation and cannot read files, +spawn processes, or open network connections. + **Public** endpoints accept any caller. **Secure** endpoints require a valid session (cookie or API key). **Critical** endpoints require extra authentication in the form of an admin password (or, for some, an API diff --git a/i18n/de.json b/i18n/de.json index 7d6843cf7..1d62d0407 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -51,6 +51,11 @@ "hex": "Hex-Farbe" }, "config": { + "adminPassword": { + "description": "Das Skript-Plugin führt einen Befehl auf Systemebene aus, der mit den Rechten des evcc-Prozesses läuft. Gib dein Administrator-Passwort erneut ein, um fortzufahren.", + "invalid": "Ungültiges Passwort. Bitte versuche es erneut.", + "title": "Potenziell gefährliche Aktion" + }, "apiKey": { "description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.", "exampleLabel": "Ausprobieren: Backup mit curl herunterladen", diff --git a/i18n/en.json b/i18n/en.json index f304c5f99..df34c3032 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -51,6 +51,11 @@ "hex": "Hex color" }, "config": { + "adminPassword": { + "description": "The script plugin executes a system-level command that runs with the permissions of the evcc process. Re-enter your admin password to continue.", + "invalid": "Invalid password. Please try again.", + "title": "Potentially dangerous operation" + }, "apiKey": { "description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.", "exampleLabel": "Try it: download a backup with curl", diff --git a/server/http.go b/server/http.go index 2956eeccc..546d5bf26 100644 --- a/server/http.go +++ b/server/http.go @@ -298,11 +298,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti "devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler}, "dirty": {"GET", "/dirty", getHandler(ConfigDirty)}, "evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)}, - "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler}, - "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler}, + "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)}, + "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)}, "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)}, - "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler}, - "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler}, + "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)}, + "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)}, "interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)}, "updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)}, "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)}, diff --git a/server/http_auth.go b/server/http_auth.go index e05071179..9aaeae36b 100644 --- a/server/http_auth.go +++ b/server/http_auth.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "net/http" "strings" "time" @@ -241,6 +242,21 @@ func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc { } } +// requireCriticalConfigAuth guards script-plugin configs: API key passes; session users must supply the admin password. +func requireCriticalConfigAuth(w http.ResponseWriter, r *http.Request, authObject auth.Auth, req configReq) bool { + if authObject.GetAuthMode() == auth.Disabled || !configHasCriticalPlugin(req) { + return true + } + if key := apiKeyFromRequest(r); key != "" && authObject.ValidateApiKey(key) { + return true + } + if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) { + jsonError(w, http.StatusPreconditionRequired, errors.New("admin password required")) + return false + } + return true +} + // ensureDbAuth guards /db/ endpoints: API key Bearer passes directly; // session users must also supply the admin password in X-Admin-Password header. func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc { diff --git a/server/http_auth_test.go b/server/http_auth_test.go new file mode 100644 index 000000000..d91b0718d --- /dev/null +++ b/server/http_auth_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/evcc-io/evcc/util/auth" + "github.com/stretchr/testify/assert" +) + +// fakeAuth is a minimal auth.Auth stub for gate tests. +type fakeAuth struct { + mode auth.AuthMode + password string + apiKey string +} + +func (f fakeAuth) GetAuthMode() auth.AuthMode { return f.mode } +func (f fakeAuth) IsAdminPasswordValid(pw string) bool { return pw != "" && pw == f.password } +func (f fakeAuth) ValidateApiKey(key string) bool { return key != "" && key == f.apiKey } +func (f fakeAuth) SetAuthMode(auth.AuthMode) {} +func (f fakeAuth) RemoveAdminPassword() {} +func (f fakeAuth) SetAdminPassword(string) error { return nil } +func (f fakeAuth) GenerateJwtToken(time.Duration) (string, error) { return "", nil } +func (f fakeAuth) ValidateJwtToken(string) bool { return true } +func (f fakeAuth) IsAdminPasswordConfigured() bool { return f.password != "" } +func (f fakeAuth) SetApiKey() (string, error) { return "", nil } +func (f fakeAuth) IsApiKeyConfigured() bool { return f.apiKey != "" } + +func TestRequireCriticalConfig(t *testing.T) { + const pw = "secret" + const key = "evcc_token" + scriptReq := configReq{Yaml: "power:\n source: script\n cmd: echo 1"} + benignReq := configReq{Yaml: "power:\n source: http\n uri: http://localhost"} + + base := fakeAuth{mode: auth.Enabled, password: pw, apiKey: key} + + tc := []struct { + name string + req configReq + header map[string]string + mode auth.AuthMode + ok bool + }{ + {"no critical plugin passes", benignReq, nil, auth.Enabled, true}, + {"disabled mode passes", scriptReq, nil, auth.Disabled, true}, + {"session without password rejected", scriptReq, nil, auth.Enabled, false}, + {"session with wrong password rejected", scriptReq, map[string]string{"X-Admin-Password": "nope"}, auth.Enabled, false}, + {"session with valid password passes", scriptReq, map[string]string{"X-Admin-Password": pw}, auth.Enabled, true}, + {"valid api key passes without password", scriptReq, map[string]string{"Authorization": "Bearer " + key}, auth.Enabled, true}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + a := base + a.mode = tc.mode + + r := httptest.NewRequest(http.MethodPost, "/api/config/test/meter", nil) + for k, v := range tc.header { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + + ok := requireCriticalConfigAuth(w, r, a, tc.req) + + assert.Equal(t, tc.ok, ok) + if !tc.ok { + assert.Equal(t, http.StatusPreconditionRequired, w.Code) + } + }) + } +} diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 67aeed82c..b92e85ef1 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -21,6 +21,7 @@ import ( "github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/tariff" + "github.com/evcc-io/evcc/util/auth" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" "github.com/evcc-io/evcc/vehicle" @@ -308,66 +309,72 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq, } // newDeviceHandler creates a new device by class -func newDeviceHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) +func newDeviceHandler(authObject auth.Auth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) - class, err := templates.ClassString(vars["class"]) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return + class, err := templates.ClassString(vars["class"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + req, err := decodeDeviceConfig(r.Body) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + if !requireCriticalConfigAuth(w, r, authObject, req) { + return + } + + var conf *config.Config + ctx, cancel, done := startDeviceTimeout() + + force := r.URL.Query().Get("force") == "true" + + switch class { + case templates.Charger: + conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force) + + case templates.Meter: + conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force) + + case templates.Vehicle: + conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force) + + case templates.Circuit: + conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force) + + case templates.Tariff: + conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force) + + case templates.Messenger: + conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force) + } + + if err != nil { + cancel() + jsonError(w, http.StatusBadRequest, err) + return + } + + // prevent context from being cancelled + close(done) + + setConfigDirty() + + res := struct { + ID int `json:"id"` + Name string `json:"name"` + }{ + ID: conf.ID, + Name: config.NameForID(conf.ID), + } + + jsonWrite(w, res) } - - req, err := decodeDeviceConfig(r.Body) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - var conf *config.Config - ctx, cancel, done := startDeviceTimeout() - - force := r.URL.Query().Get("force") == "true" - - switch class { - case templates.Charger: - conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force) - - case templates.Meter: - conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force) - - case templates.Vehicle: - conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force) - - case templates.Circuit: - conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force) - - case templates.Tariff: - conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force) - - case templates.Messenger: - conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force) - } - - if err != nil { - cancel() - jsonError(w, http.StatusBadRequest, err) - return - } - - // prevent context from being cancelled - close(done) - - setConfigDirty() - - res := struct { - ID int `json:"id"` - Name string `json:"name"` - }{ - ID: conf.ID, - Name: config.NameForID(conf.ID), - } - - jsonWrite(w, res) } func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error { @@ -388,69 +395,75 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req } // updateDeviceHandler updates database device's configuration by class -func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) +func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) - class, err := templates.ClassString(vars["class"]) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return + class, err := templates.ClassString(vars["class"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + id, err := strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + req, err := decodeDeviceConfig(r.Body) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + if !requireCriticalConfigAuth(w, r, authObject, req) { + return + } + + ctx, cancel, done := startDeviceTimeout() + + force := r.URL.Query().Get("force") == "true" + + switch class { + case templates.Charger: + err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force) + + case templates.Meter: + err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force) + + case templates.Vehicle: + err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force) + + case templates.Circuit: + err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force) + + case templates.Tariff: + err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force) + + case templates.Messenger: + err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force) + } + + setConfigDirty() + + if err != nil { + cancel() + jsonError(w, http.StatusBadRequest, err) + return + } + + // prevent context from being cancelled + close(done) + + res := struct { + ID int `json:"id"` + }{ + ID: id, + } + + jsonWrite(w, res) } - - id, err := strconv.Atoi(vars["id"]) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - req, err := decodeDeviceConfig(r.Body) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - ctx, cancel, done := startDeviceTimeout() - - force := r.URL.Query().Get("force") == "true" - - switch class { - case templates.Charger: - err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force) - - case templates.Meter: - err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force) - - case templates.Vehicle: - err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force) - - case templates.Circuit: - err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force) - - case templates.Tariff: - err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force) - - case templates.Messenger: - err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force) - } - - setConfigDirty() - - if err != nil { - cancel() - jsonError(w, http.StatusBadRequest, err) - return - } - - // prevent context from being cancelled - close(done) - - res := struct { - ID int `json:"id"` - }{ - ID: id, - } - - jsonWrite(w, res) } func configurableDevice[T any](name string, h config.Handler[T]) (config.ConfigurableDevice[T], error) { @@ -644,67 +657,73 @@ func testConfig[T any](ctx context.Context, id int, class templates.Class, req c } // testConfigHandler tests a configuration by class -func testConfigHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) +func testConfigHandler(authObject auth.Auth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) - class, err := templates.ClassString(vars["class"]) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - var id int - if vars["id"] != "" { - // test existing device with updated config - id, err = strconv.Atoi(vars["id"]) + class, err := templates.ClassString(vars["class"]) if err != nil { jsonError(w, http.StatusBadRequest, err) return } + + var id int + if vars["id"] != "" { + // test existing device with updated config + id, err = strconv.Atoi(vars["id"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + } + + req, err := decodeDeviceConfig(r.Body) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + if !requireCriticalConfigAuth(w, r, authObject, req) { + return + } + + var instance any + ctx, cancel, done := startDeviceTimeout() + + switch class { + case templates.Charger: + instance, err = testConfig(ctx, id, class, req, charger.NewFromConfig, config.Chargers()) + + case templates.Meter: + instance, err = testConfig(ctx, id, class, req, meter.NewFromConfig, config.Meters()) + + case templates.Vehicle: + instance, err = testConfig(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles()) + + case templates.Circuit: + instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits()) + + case templates.Tariff: + instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs()) + + case templates.Messenger: + instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers()) + } + + if err != nil { + cancel() + jsonError(w, http.StatusBadRequest, err) + return + } + + // prevent context from being cancelled during test + close(done) + defer cancel() + + // bound the value-probe phase so a blocking getter cannot stall the response + probeCtx, probeCancel := context.WithTimeout(r.Context(), 10*time.Second) + defer probeCancel() + + jsonWrite(w, testInstance(probeCtx, instance)) } - - req, err := decodeDeviceConfig(r.Body) - if err != nil { - jsonError(w, http.StatusBadRequest, err) - return - } - - var instance any - ctx, cancel, done := startDeviceTimeout() - - switch class { - case templates.Charger: - instance, err = testConfig(ctx, id, class, req, charger.NewFromConfig, config.Chargers()) - - case templates.Meter: - instance, err = testConfig(ctx, id, class, req, meter.NewFromConfig, config.Meters()) - - case templates.Vehicle: - instance, err = testConfig(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles()) - - case templates.Circuit: - instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits()) - - case templates.Tariff: - instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs()) - - case templates.Messenger: - instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers()) - } - - if err != nil { - cancel() - jsonError(w, http.StatusBadRequest, err) - return - } - - // prevent context from being cancelled during test - close(done) - defer cancel() - - // bound the value-probe phase so a blocking getter cannot stall the response - probeCtx, probeCancel := context.WithTimeout(r.Context(), 10*time.Second) - defer probeCancel() - - jsonWrite(w, testInstance(probeCtx, instance)) } diff --git a/server/http_config_helper.go b/server/http_config_helper.go index ae26bb4c1..e15e4e567 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -528,6 +528,38 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val } } +var criticalPluginSources = []string{"script"} + +func configHasCriticalPlugin(req configReq) bool { + if req.Yaml != "" { + var m map[string]any + if err := yaml.Unmarshal([]byte(req.Yaml), &m); err != nil { + return false // malformed yaml already rejected by decodeDeviceConfig + } + return valueHasCriticalSource(m) + } + return valueHasCriticalSource(req.Other) +} + +func valueHasCriticalSource(v any) bool { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + if strings.EqualFold(k, "source") { + if s, ok := val.(string); ok && slices.Contains(criticalPluginSources, strings.ToLower(strings.TrimSpace(s))) { + return true + } + } + if valueHasCriticalSource(val) { + return true + } + } + case []any: + return slices.ContainsFunc(t, valueHasCriticalSource) + } + return false +} + // decodeDeviceConfig extracts device configuration and yaml details func decodeDeviceConfig(r io.Reader) (configReq, error) { var res configReq diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go index bbc3ade09..942717448 100644 --- a/server/http_config_helper_test.go +++ b/server/http_config_helper_test.go @@ -210,6 +210,38 @@ func TestMergeMaskedFiltersBehavior(t *testing.T) { assert.NotContains(t, result, "outdatedField") } +func TestConfigHasCriticalPlugin(t *testing.T) { + tc := []struct { + name string + yaml string + want bool + }{ + {"script top level", "power:\n source: script\n cmd: echo 1", true}, + {"script case insensitive", "power:\n Source: SCRIPT\n cmd: echo 1", true}, + {"script nested in calc", "power:\n source: calc\n add:\n - source: const\n value: 1\n - source: script\n cmd: echo 1", true}, + {"script nested in sequence set", "power:\n source: sequence\n set:\n - source: script\n cmd: echo 1", true}, + {"script nested in js transformation", "power:\n source: js\n script: x\n in:\n - name: x\n type: float\n source: script\n cmd: echo 1", true}, + {"js without script", "power:\n source: js\n script: \"x = 1\"", false}, + {"go without script", "power:\n source: go\n script: \"return 1\"", false}, + {"http without script", "power:\n source: http\n uri: http://localhost", false}, + {"plain template", "power: 100", false}, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, configHasCriticalPlugin(configReq{Yaml: tc.yaml})) + }) + } + + // non-yaml custom config carried in Other + assert.True(t, configHasCriticalPlugin(configReq{ + Other: map[string]any{"power": map[string]any{"source": "script", "cmd": "echo 1"}}, + })) + assert.False(t, configHasCriticalPlugin(configReq{ + Other: map[string]any{"template": "tesla"}, + })) +} + func TestFilterValidTemplateParams(t *testing.T) { conf := map[string]any{ "template": "generic", diff --git a/tests/config-script-plugin.spec.ts b/tests/config-script-plugin.spec.ts new file mode 100644 index 000000000..bfcc7f9da --- /dev/null +++ b/tests/config-script-plugin.spec.ts @@ -0,0 +1,107 @@ +import { test, expect, type Page } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +const SCRIPT_YAML = `power: + source: script + cmd: echo 9999`; + +const UPDATED_YAML = `power: + source: script + cmd: echo 8888`; + +async function login(page: Page) { + const loginModal = page.getByTestId("login-modal"); + await expectModalVisible(loginModal); + await loginModal.getByLabel("Administrator Password").fill("secret"); + await loginModal.getByRole("button", { name: "Login" }).click(); + await expectModalHidden(loginModal); +} + +async function addCustomGridMeter(page: Page, yaml: string) { + await page.getByRole("button", { name: "Add grid meter" }).click(); + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("User-defined device"); + const editor = modal.getByTestId("yaml-editor"); + await expect(editor).toBeVisible(); + await editorClear(editor); + await editorPaste(editor, page, yaml); + return modal; +} + +test.describe("script plugin requires admin password", async () => { + test("caches password across validate and create", async ({ page }) => { + await start(undefined, "password.sql", ""); + await page.goto("/#/config"); + await login(page); + + const meterModal = await addCustomGridMeter(page, SCRIPT_YAML); + const prompt = meterModal.getByTestId("admin-password-prompt"); + const validate = meterModal.getByTestId("test-result").getByRole("link", { name: "validate" }); + + // validate without password reveals the field + await validate.click(); + await expect(prompt).toBeVisible(); + + // editing the config hides the field again + await editorClear(meterModal.getByTestId("yaml-editor")); + await editorPaste(meterModal.getByTestId("yaml-editor"), page, SCRIPT_YAML); + await expect(prompt).not.toBeVisible(); + + // wrong password keeps the field with an invalid hint + await validate.click(); + await expect(prompt).toBeVisible(); + await prompt.getByLabel("Administrator Password").fill("wrong"); + await validate.click(); + await expect(prompt.getByText("Invalid password. Please try again.")).toBeVisible(); + + // correct password validates and hides the field + await prompt.getByLabel("Administrator Password").fill("secret"); + await validate.click(); + await expect(meterModal.getByTestId("test-result")).toContainText("Status: successful"); + await expect(prompt).not.toBeVisible(); + + // save reuses the cached password, no field reappears + await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(prompt).not.toBeVisible(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("grid")).toBeVisible(); + }); + + test("re-prompts on update after reload", async ({ page }) => { + await start(undefined, "password.sql", ""); + await page.goto("/#/config"); + await login(page); + + // create a script meter (enter the password once) + const meterModal = await addCustomGridMeter(page, SCRIPT_YAML); + const prompt = meterModal.getByTestId("admin-password-prompt"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(prompt).toBeVisible(); + await prompt.getByLabel("Administrator Password").fill("secret"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("grid")).toBeVisible(); + + // reload drops the cached password + await page.reload(); + + // editing and saving the existing device prompts again + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await editorClear(meterModal.getByTestId("yaml-editor")); + await editorPaste(meterModal.getByTestId("yaml-editor"), page, UPDATED_YAML); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(prompt).toBeVisible(); + await prompt.getByLabel("Administrator Password").fill("secret"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + }); +});
{{ $t("config.adminPassword.title") }}
{{ $t("config.adminPassword.description") }}