Merge commit from fork

* Config: require admin password for script plugins

* refactor
This commit is contained in:
Michael Geers 2026-06-24 15:45:15 +02:00 • committed by GitHub
parent a193236395
commit 4c907c5afa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 594 additions and 207 deletions

View file

@ -0,0 +1,27 @@
<template>
<div class="alert alert-warning mt-4 pb-0" role="alert" data-testid="admin-password-prompt">
<p class="fw-bold mb-2">{{ $t("config.adminPassword.title") }}</p>
<p>{{ $t("config.adminPassword.description") }}</p>
<PasswordInput
class="text-body"
:password="password"
:error="invalid ? $t('config.adminPassword.invalid') : ''"
@update:password="$emit('update:password', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import PasswordInput from "./PasswordInput.vue";
export default defineComponent({
name: "AdminPasswordPrompt",
components: { PasswordInput },
props: {
password: { type: String, default: "" },
invalid: Boolean,
},
emits: ["update:password"],
});
</script>

View file

@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<slot name="before-test"></slot>
<TestResult <TestResult
v-if="testState" v-if="testState"
v-bind="testState" v-bind="testState"

View file

@ -179,6 +179,13 @@
@remove="handleRemove" @remove="handleRemove"
@test="testManually" @test="testManually"
> >
<template #before-test>
<AdminPasswordPrompt
v-if="adminPasswordRequired"
v-model:password="adminPasswordValue"
:invalid="adminPasswordInvalid"
/>
</template>
<template #after-test> <template #after-test>
<slot name="after-test" :values="values"></slot> <slot name="after-test" :values="values"></slot>
</template> </template>
@ -207,6 +214,7 @@ import AuthConnectButton from "../AuthConnectButton.vue";
import { initialTestState, performTest } from "../utils/test"; import { initialTestState, performTest } from "../utils/test";
import { reportValidityInModal } from "../utils/reportValidityInModal"; import { reportValidityInModal } from "../utils/reportValidityInModal";
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider"; import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue";
import sleep from "@/utils/sleep"; import sleep from "@/utils/sleep";
import { ConfigType } from "@/types/evcc"; import { ConfigType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc"; import type { DeviceType, Timeout } from "@/types/evcc";
@ -223,6 +231,7 @@ import {
applyDefaultsFromTemplate, applyDefaultsFromTemplate,
createDeviceUtils, createDeviceUtils,
fetchServiceValues, fetchServiceValues,
ADMIN_PASSWORD_REQUIRED,
} from "./index"; } from "./index";
import deepEqual from "@/utils/deepEqual"; import deepEqual from "@/utils/deepEqual";
@ -244,6 +253,7 @@ export default defineComponent({
YamlEntry, YamlEntry,
AuthCodeDisplay, AuthCodeDisplay,
AuthConnectButton, AuthConnectButton,
AdminPasswordPrompt,
}, },
props: { props: {
deviceType: { type: String as PropType<DeviceType>, required: true }, deviceType: { type: String as PropType<DeviceType>, required: true },
@ -313,6 +323,9 @@ export default defineComponent({
test: initialTestState(), test: initialTestState(),
serviceValues: {} as Record<string, string[]>, serviceValues: {} as Record<string, string[]>,
serviceValuesTimer: null as Timeout | null, serviceValuesTimer: null as Timeout | null,
adminPasswordValue: "",
adminPasswordRequired: false,
adminPasswordInvalid: false,
}; };
}, },
computed: { computed: {
@ -546,10 +559,15 @@ export default defineComponent({
if (this.test.isError || this.test.isSuccess) { if (this.test.isError || this.test.isSuccess) {
this.test = initialTestState(); this.test = initialTestState();
} }
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
this.updateServiceValues(); this.updateServiceValues();
}, },
deep: true, deep: true,
}, },
adminPasswordValue() {
this.adminPasswordInvalid = false;
},
authValues: { authValues: {
handler() { handler() {
if (this.authRequired) { if (this.authRequired) {
@ -699,22 +717,35 @@ export default defineComponent({
this.saving = true; this.saving = true;
try { 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.saving = false;
this.succeeded = true; this.succeeded = true;
await sleep(500); await sleep(500);
this.$emit("added", name); this.$emit("added", res.data.name);
await closeModal({ action: "added", name }); await closeModal({ action: "added", name: res.data.name });
} catch (e) { } catch (e) {
handleError(e, "create failed");
this.saving = false; this.saving = false;
handleError(e, "create failed");
} }
}, },
async testManually() { async testManually() {
await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement); await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement);
}, },
async testDevice() { 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) { async update(force = false) {
if (this.test.isUnknown && !force) { if (this.test.isUnknown && !force) {
@ -729,16 +760,26 @@ export default defineComponent({
} }
this.saving = true; this.saving = true;
try { 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.saving = false;
this.succeeded = true; this.succeeded = true;
await sleep(500); await sleep(500);
this.$emit("updated"); this.$emit("updated");
await closeModal({ action: "updated" }); await closeModal({ action: "updated" });
} catch (e) { } catch (e) {
this.saving = false;
console.error("update failed", e); console.error("update failed", e);
handleError(e, "update failed"); handleError(e, "update failed");
this.saving = false;
} }
}, },
async remove() { async remove() {
@ -752,6 +793,8 @@ export default defineComponent({
}, },
handleOpen() { handleOpen() {
this.isModalVisible = true; this.isModalVisible = true;
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
}, },
handleClose() { handleClose() {
this.$emit("close"); this.$emit("close");

View file

@ -3,6 +3,16 @@ import { ConfigType } from "@/types/evcc";
import api from "@/api"; import api from "@/api";
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder"; 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<string, string> {
return adminPassword ? { "X-Admin-Password": adminPassword } : {};
}
export type Product = { export type Product = {
group: string; group: string;
name: string; name: string;
@ -189,17 +199,25 @@ export const fetchServiceValues = async (
}; };
export function createDeviceUtils(deviceType: DeviceType) { 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}`; let url = `config/test/${deviceType}`;
if (id !== undefined) { if (id !== undefined) {
url += `/merge/${id}`; 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) { function update(id: number, data: any, force = false, adminPassword = "") {
const params = { force }; const opts = {
return api.put(`config/devices/${deviceType}/${id}`, data, { params }); headers: adminPasswordHeader(adminPassword),
validateStatus: allowAdminPasswordRequired,
params: { force },
};
return api.put(`config/devices/${deviceType}/${id}`, data, opts);
} }
function remove(id: number) { function remove(id: number) {
@ -211,10 +229,13 @@ export function createDeviceUtils(deviceType: DeviceType) {
return response.data; return response.data;
} }
async function create(data: any, force = false) { function create(data: any, force = false, adminPassword = "") {
const params = { force }; const opts = {
const response = await api.post(`config/devices/${deviceType}`, data, { params }); headers: adminPasswordHeader(adminPassword),
return response.data; validateStatus: allowAdminPasswordRequired,
params: { force },
};
return api.post(`config/devices/${deviceType}`, data, opts);
} }
async function loadProducts(lang?: string, usage?: string) { async function loadProducts(lang?: string, usage?: string) {

View file

@ -61,7 +61,6 @@ export default {
<style scoped> <style scoped>
.editor { .editor {
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
min-height: 150px;
} }
.editor-loading { .editor-loading {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;

View file

@ -47,17 +47,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
@import "../../../css/breakpoints.css";
.editor-container { .editor-container {
width: 100%;
overflow: hidden; overflow: hidden;
margin: 0 -1rem 0 -1.25rem;
}
/* reset margins on lg */
@media (--lg-and-up) {
.editor-container {
margin: 0;
}
} }
</style> </style>

View file

@ -1,5 +1,6 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import sleep from "@/utils/sleep"; import sleep from "@/utils/sleep";
import { ADMIN_PASSWORD_REQUIRED } from "../DeviceModal/index";
import { reportValidityInModal } from "./reportValidityInModal"; import { reportValidityInModal } from "./reportValidityInModal";
export type TestState = { export type TestState = {
@ -36,6 +37,10 @@ export const performTest = async (
const startTime = Date.now(); const startTime = Date.now();
try { try {
const res = await api(); 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.isError = false;
state.error = null; state.error = null;
state.errorLine = null; state.errorLine = null;

View file

@ -88,11 +88,21 @@ achieves the same effect (the previous key stops working immediately).
| State / read-only / basic charging control | Public | | | State / read-only / basic charging control | Public | |
| Set or update admin password | Public | admin password | | Set or update admin password | Public | admin password |
| Configuration | Secure | | | Configuration | Secure | |
| Configuration embedding a script plugin | Critical | api key or admin password |
| System: logs, cache, shutdown | Secure | | | System: logs, cache, shutdown | Secure | |
| API key status | Secure | | | API key status | Secure | |
| System: backup / restore / reset | Critical | api key or admin password | | System: backup / restore / reset | Critical | api key or admin password |
| API key regenerate | Critical | 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 **Public** endpoints accept any caller. **Secure** endpoints require a
valid session (cookie or API key). **Critical** endpoints require extra valid session (cookie or API key). **Critical** endpoints require extra
authentication in the form of an admin password (or, for some, an API authentication in the form of an admin password (or, for some, an API

View file

@ -51,6 +51,11 @@
"hex": "Hex-Farbe" "hex": "Hex-Farbe"
}, },
"config": { "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": { "apiKey": {
"description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.", "description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.",
"exampleLabel": "Ausprobieren: Backup mit curl herunterladen", "exampleLabel": "Ausprobieren: Backup mit curl herunterladen",

View file

@ -51,6 +51,11 @@
"hex": "Hex color" "hex": "Hex color"
}, },
"config": { "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": { "apiKey": {
"description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.", "description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.",
"exampleLabel": "Try it: download a backup with curl", "exampleLabel": "Try it: download a backup with curl",

View file

@ -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}, "devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)}, "dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)}, "evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler}, "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler}, "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)},
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)}, "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler}, "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)},
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler}, "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)}, "interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)}, "updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)}, "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},

View file

@ -2,6 +2,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "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; // ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
// session users must also supply the admin password in X-Admin-Password header. // session users must also supply the admin password in X-Admin-Password header.
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc { func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {

74
server/http_auth_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -21,6 +21,7 @@ import (
"github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff" "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/config"
"github.com/evcc-io/evcc/util/templates" "github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle" "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 // newDeviceHandler creates a new device by class
func newDeviceHandler(w http.ResponseWriter, r *http.Request) { func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
vars := mux.Vars(r) return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"]) class, err := templates.ClassString(vars["class"])
if err != nil { if err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return 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 { 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 // updateDeviceHandler updates database device's configuration by class
func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc {
vars := mux.Vars(r) return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"]) class, err := templates.ClassString(vars["class"])
if err != nil { if err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return 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) { 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 // testConfigHandler tests a configuration by class
func testConfigHandler(w http.ResponseWriter, r *http.Request) { func testConfigHandler(authObject auth.Auth) http.HandlerFunc {
vars := mux.Vars(r) return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"]) 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 { if err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return 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))
} }

View file

@ -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 // decodeDeviceConfig extracts device configuration and yaml details
func decodeDeviceConfig(r io.Reader) (configReq, error) { func decodeDeviceConfig(r io.Reader) (configReq, error) {
var res configReq var res configReq

View file

@ -210,6 +210,38 @@ func TestMergeMaskedFiltersBehavior(t *testing.T) {
assert.NotContains(t, result, "outdatedField") 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) { func TestFilterValidTemplateParams(t *testing.T) {
conf := map[string]any{ conf := map[string]any{
"template": "generic", "template": "generic",

View file

@ -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);
});
});