Merge commit from fork

This commit is contained in:
Michael Geers 2026-06-25 17:47:30 +02:00 • committed by GitHub
parent fc84062905
commit 6f6cb2c907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 157 additions and 5 deletions

View file

@ -28,6 +28,12 @@
</div>
<slot name="extra" />
<AdminPasswordPrompt
v-if="adminPasswordRequired"
v-model:password="adminPassword"
:invalid="adminPasswordInvalid"
/>
<div class="mt-4 d-flex justify-content-between">
<button
type="button"
@ -59,13 +65,15 @@
<script>
import GenericModal from "../Helper/GenericModal.vue";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue";
import api from "@/api";
import { docsPrefix } from "@/i18n";
import YamlEditorContainer from "./YamlEditorContainer.vue";
import { ADMIN_PASSWORD_REQUIRED } from "./DeviceModal/index";
export default {
name: "YamlModal",
components: { GenericModal, ErrorMessage, YamlEditorContainer },
components: { GenericModal, ErrorMessage, YamlEditorContainer, AdminPasswordPrompt },
props: {
title: String,
description: String,
@ -87,6 +95,9 @@ export default {
yaml: "",
serverYaml: "",
modalVisible: false,
adminPassword: "",
adminPasswordRequired: false,
adminPasswordInvalid: false,
};
},
computed: {
@ -97,6 +108,11 @@ export default {
return this.yaml === this.serverYaml && this.yaml !== "";
},
},
watch: {
adminPassword() {
this.adminPasswordInvalid = false;
},
},
methods: {
reset() {
this.yaml = "";
@ -104,6 +120,9 @@ export default {
this.error = "";
this.saving = false;
this.errorLine = undefined;
// keep adminPassword across reopens (like BaseDeviceModal), cleared on reload
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
},
async open() {
this.reset();
@ -130,8 +149,15 @@ export default {
try {
const data = this.yaml === this.defaultYaml ? "" : this.yaml;
const res = await api.post(this.endpoint, data, {
validateStatus: (code) => [200, 400].includes(code),
headers: this.adminPassword ? { "X-Admin-Password": this.adminPassword } : {},
validateStatus: (code) => [200, 400, ADMIN_PASSWORD_REQUIRED].includes(code),
});
if (res.status === ADMIN_PASSWORD_REQUIRED) {
this.adminPasswordRequired = true;
this.adminPasswordInvalid = !!this.adminPassword;
this.saving = false;
return;
}
if (res.status === 200) {
this.$emit("changed");
this.$refs.modal.close();

View file

@ -323,7 +323,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
} {
other, struc := fun()
routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetStringHandler(key)}
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetYamlHandler(key, other, struc)}
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetYamlHandler(key, other, struc, auth)}
routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteHandler(key)}
}

View file

@ -33,6 +33,7 @@ func TestRequireCriticalConfig(t *testing.T) {
const pw = "secret"
const key = "evcc_token"
scriptReq := configReq{Yaml: "power:\n source: script\n cmd: echo 1"}
scriptListReq := configReq{Yaml: "- name: main\n getmaxcurrent:\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}
@ -50,6 +51,8 @@ func TestRequireCriticalConfig(t *testing.T) {
{"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},
{"yaml list with script rejected without password", scriptListReq, nil, auth.Enabled, false},
{"yaml list with script passes with password", scriptListReq, map[string]string{"X-Admin-Password": pw}, auth.Enabled, true},
}
for _, tc := range tc {

View file

@ -532,7 +532,8 @@ var criticalPluginSources = []string{"script"}
func configHasCriticalPlugin(req configReq) bool {
if req.Yaml != "" {
var m map[string]any
// any, not map: global yaml configs (circuits) are a list
var m any
if err := yaml.Unmarshal([]byte(req.Yaml), &m); err != nil {
return false // malformed yaml already rejected by decodeDeviceConfig
}

View file

@ -225,6 +225,8 @@ func TestConfigHasCriticalPlugin(t *testing.T) {
{"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},
{"script in yaml list", "- name: main\n getmaxcurrent:\n source: script\n cmd: echo 1", true},
{"benign yaml list", "- name: main\n maxcurrent: 16", false},
}
for _, tc := range tc {

View file

@ -11,6 +11,7 @@ import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/redact"
"github.com/gorilla/mux"
"go.yaml.in/yaml/v4"
@ -55,7 +56,7 @@ func settingsSetDurationHandler(key string, pub publisher) http.HandlerFunc {
}
}
func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
func settingsSetYamlHandler(key string, other, struc any, authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
if err != nil {
@ -73,6 +74,11 @@ func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
return
}
// script plugins need the admin password
if !requireCriticalConfigAuth(w, r, authObject, configReq{Yaml: string(b)}) {
return
}
val := strings.TrimSpace(string(b))
settings.SetString(key, val)
setConfigDirty()

View file

@ -0,0 +1,42 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
)
func TestSettingsSetYamlHandlerCriticalPlugin(t *testing.T) {
const pw = "secret"
const key = "test_circuits_guard"
body := "- name: main\n getmaxcurrent:\n source: script\n cmd: echo 1"
a := fakeAuth{mode: auth.Enabled, password: pw}
h := settingsSetYamlHandler(key, []map[string]any{}, []config.Named{}, a)
// session without password is rejected and nothing is persisted
r := httptest.NewRequest(http.MethodPost, "/circuits", strings.NewReader(body))
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusPreconditionRequired, w.Code)
_, err := settings.String(key)
assert.ErrorIs(t, err, settings.ErrNotFound)
// valid admin password persists the config
r = httptest.NewRequest(http.MethodPost, "/circuits", strings.NewReader(body))
r.Header.Set("X-Admin-Password", pw)
w = httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusOK, w.Code)
got, err := settings.String(key)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(body), got)
}

View file

@ -0,0 +1,72 @@
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();
});
// circuits config is a yaml list, exercising the list-shaped critical-plugin detection
const SCRIPT_YAML = `- name: main
getmaxcurrent:
source: script
cmd: echo 9999`;
const UPDATED_YAML = `- name: main
getmaxcurrent:
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 openCircuitsModal(page: Page, yaml: string) {
await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("circuits-modal");
await expectModalVisible(modal);
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toBeVisible();
await editorClear(editor);
await editorPaste(editor, page, yaml);
return modal;
}
test.describe("yaml config with script plugin requires admin password", async () => {
test("caches password across save and reopen", async ({ page }) => {
await start(undefined, "password.sql", "");
await page.goto("/#/config");
await login(page);
const modal = await openCircuitsModal(page, SCRIPT_YAML);
const prompt = modal.getByTestId("admin-password-prompt");
const save = modal.getByRole("button", { name: "Save" });
// save without password reveals the field, modal stays open
await save.click();
await expect(prompt).toBeVisible();
await expectModalVisible(modal);
// wrong password keeps the field with an invalid hint
await prompt.getByLabel("Administrator Password").fill("wrong");
await save.click();
await expect(prompt.getByText("Invalid password. Please try again.")).toBeVisible();
// correct password saves and closes the modal
await prompt.getByLabel("Administrator Password").fill("secret");
await save.click();
await expectModalHidden(modal);
// reopen and edit: cached password is reused, no prompt
const reopened = await openCircuitsModal(page, UPDATED_YAML);
await reopened.getByRole("button", { name: "Save" }).click();
await expect(reopened.getByTestId("admin-password-prompt")).not.toBeVisible();
await expectModalHidden(reopened);
});
});