Merge commit from fork
* Config: require admin password for script plugins * refactor
This commit is contained in:
parent
a193236395
commit
4c907c5afa
17 changed files with 594 additions and 207 deletions
27
assets/js/components/Auth/AdminPasswordPrompt.vue
Normal file
27
assets/js/components/Auth/AdminPasswordPrompt.vue
Normal 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>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<slot name="before-test"></slot>
|
||||
|
||||
<TestResult
|
||||
v-if="testState"
|
||||
v-bind="testState"
|
||||
|
|
|
|||
|
|
@ -179,6 +179,13 @@
|
|||
@remove="handleRemove"
|
||||
@test="testManually"
|
||||
>
|
||||
<template #before-test>
|
||||
<AdminPasswordPrompt
|
||||
v-if="adminPasswordRequired"
|
||||
v-model:password="adminPasswordValue"
|
||||
:invalid="adminPasswordInvalid"
|
||||
/>
|
||||
</template>
|
||||
<template #after-test>
|
||||
<slot name="after-test" :values="values"></slot>
|
||||
</template>
|
||||
|
|
@ -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<DeviceType>, required: true },
|
||||
|
|
@ -313,6 +323,9 @@ export default defineComponent({
|
|||
test: initialTestState(),
|
||||
serviceValues: {} as Record<string, string[]>,
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ export default {
|
|||
<style scoped>
|
||||
.editor {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
min-height: 150px;
|
||||
}
|
||||
.editor-loading {
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
|
|
|||
|
|
@ -47,17 +47,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "../../../css/breakpoints.css";
|
||||
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0 -1rem 0 -1.25rem;
|
||||
}
|
||||
/* reset margins on lg */
|
||||
@media (--lg-and-up) {
|
||||
.editor-container {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
74
server/http_auth_test.go
Normal file
74
server/http_auth_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,7 +309,8 @@ 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) {
|
||||
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"])
|
||||
|
|
@ -323,6 +325,10 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||
return
|
||||
}
|
||||
|
||||
var conf *config.Config
|
||||
ctx, cancel, done := startDeviceTimeout()
|
||||
|
||||
|
|
@ -368,6 +374,7 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
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,7 +395,8 @@ 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) {
|
||||
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"])
|
||||
|
|
@ -409,6 +417,10 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel, done := startDeviceTimeout()
|
||||
|
||||
force := r.URL.Query().Get("force") == "true"
|
||||
|
|
@ -451,6 +463,7 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
func configurableDevice[T any](name string, h config.Handler[T]) (config.ConfigurableDevice[T], error) {
|
||||
|
|
@ -644,7 +657,8 @@ 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) {
|
||||
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"])
|
||||
|
|
@ -669,6 +683,10 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||
return
|
||||
}
|
||||
|
||||
var instance any
|
||||
ctx, cancel, done := startDeviceTimeout()
|
||||
|
||||
|
|
@ -707,4 +725,5 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||
defer probeCancel()
|
||||
|
||||
jsonWrite(w, testInstance(probeCtx, instance))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
107
tests/config-script-plugin.spec.ts
Normal file
107
tests/config-script-plugin.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue