Merge commit from fork
This commit is contained in:
parent
fc84062905
commit
6f6cb2c907
8 changed files with 157 additions and 5 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
42
server/http_global_settings_handler_test.go
Normal file
42
server/http_global_settings_handler_test.go
Normal 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)
|
||||
}
|
||||
72
tests/config-yaml-script-plugin.spec.ts
Normal file
72
tests/config-yaml-script-plugin.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue