iobroker.eos-admin 7.10.2 → 7.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL_TEST_V7.10.4.md +9 -0
- package/MERGE_UPDATE_README_V7.10.4.md +3 -0
- package/NEXOWATT_EOS_BUILD_INFO.json +16 -15
- package/NEXOWATT_EOS_PREBUILT_MANIFEST.json +17 -12
- package/PUBLISH_STABLE_V7.10.4.md +10 -0
- package/README.md +4 -4
- package/README_STABILITY_V7.10.4.md +12 -0
- package/RELEASE_ACCEPTANCE_V7.10.4.md +3 -0
- package/RELEASE_NOTES_V7.10.4.md +11 -0
- package/adminWww/css/eos-auto-update.css +4 -3
- package/adminWww/index.html +3 -2
- package/adminWww/js/eos-auto-update.js +127 -33
- package/adminWww/nexowatt-role-security.js +336 -0
- package/build/lib/eosRoleSecurity.js +310 -0
- package/build/main.js +5 -0
- package/io-package.json +10 -5
- package/package.json +17 -12
- package/src-admin/README.md +69 -0
- package/src-admin/nexowatt-role-security.js +336 -0
- package/tools/nexowatt-auto-update-placement-selftest.cjs +46 -0
- package/tools/nexowatt-merge-update-selftest.cjs +1 -1
- package/tools/nexowatt-role-security-selftest.cjs +43 -0
- package/tools/{nexowatt-stable-v7102-selftest.cjs → nexowatt-stable-v7103-selftest.cjs} +21 -11
- package/tools/nexowatt-validate-package.cjs +18 -9
- package/INSTALL_TEST_V7.10.2.md +0 -22
- package/MERGE_UPDATE_README_V7.10.2.md +0 -11
- package/PUBLISH_STABLE_V7.10.2.md +0 -21
- package/README_STABILITY_V7.10.2.md +0 -20
- package/RELEASE_ACCEPTANCE_V7.10.2.md +0 -13
- package/RELEASE_NOTES_V7.10.2.md +0 -11
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const VERSION = "7.10.4";
|
|
5
|
+
const HIDDEN = "data-eos-role-hidden";
|
|
6
|
+
const READONLY = "data-eos-readonly";
|
|
7
|
+
const state = { role: "unknown", backupActive: null, user: "", appliedAt: 0 };
|
|
8
|
+
|
|
9
|
+
const adminOnlyTokens = [
|
|
10
|
+
"app-center", "app center", "appcenter", "lizenz", "license", "simulation", "simulator",
|
|
11
|
+
"expertenmodus", "expert mode", "xterm", "shell-konsole", "shell console", "zugänge & rechte",
|
|
12
|
+
"zugaenge & rechte", "access & rights", "user management", "benutzerverwaltung"
|
|
13
|
+
];
|
|
14
|
+
const backupTokens = ["sicherung", "backup", "backitup"];
|
|
15
|
+
const dataPointTokens = ["datenpunkte", "datapoints", "objects", "objekte"];
|
|
16
|
+
const dangerousWriteTokens = [
|
|
17
|
+
"wert schreiben", "wert setzen", "schreiben", "write value", "set value", "setstate", "speichern",
|
|
18
|
+
"save", "anwenden", "apply", "löschen", "delete", "bearbeiten", "edit", "stift"
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const normalize = value => String(value == null ? "" : value).trim().toLowerCase();
|
|
22
|
+
const textOf = el => normalize([
|
|
23
|
+
el && el.textContent,
|
|
24
|
+
el && el.getAttribute && el.getAttribute("title"),
|
|
25
|
+
el && el.getAttribute && el.getAttribute("aria-label"),
|
|
26
|
+
el && el.getAttribute && el.getAttribute("data-testid"),
|
|
27
|
+
el && el.getAttribute && el.getAttribute("href"),
|
|
28
|
+
el && el.getAttribute && el.getAttribute("to"),
|
|
29
|
+
el && el.id,
|
|
30
|
+
el && el.className && typeof el.className === "string" ? el.className : ""
|
|
31
|
+
].filter(Boolean).join(" "));
|
|
32
|
+
|
|
33
|
+
function classify(value) {
|
|
34
|
+
const text = normalize(value);
|
|
35
|
+
if (!text) return "unknown";
|
|
36
|
+
if (/^(?:system\.user\.)?admin(?:istrator)?$/.test(text) || /(?:^|[.:/_ -])admin(?:istrator)?(?:$|[.:/_ -])/.test(text)) return "admin";
|
|
37
|
+
if (/installer|installateur|inbetriebnahme/.test(text)) return "installer";
|
|
38
|
+
if (/endkunde|customer|smarthome|guest|gast|(?:^|\s)user(?:$|\s)/.test(text)) return "customer";
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function roleFromGlobals() {
|
|
43
|
+
const candidates = [
|
|
44
|
+
window.__NEXOWATT_ROLE__, window.__EOS_ROLE__, window.eosRole,
|
|
45
|
+
window.currentUser && window.currentUser.role,
|
|
46
|
+
window.user && window.user.role,
|
|
47
|
+
document.documentElement.getAttribute("data-role"),
|
|
48
|
+
document.body && document.body.getAttribute("data-role")
|
|
49
|
+
];
|
|
50
|
+
for (const value of candidates) {
|
|
51
|
+
const role = classify(value);
|
|
52
|
+
if (role !== "unknown") return role;
|
|
53
|
+
}
|
|
54
|
+
return "unknown";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function roleFromStorage() {
|
|
58
|
+
const stores = [window.sessionStorage, window.localStorage];
|
|
59
|
+
const keys = ["eos.role", "eosRole", "role", "userRole", "nexowatt.role", "auth.role", "currentRole"];
|
|
60
|
+
for (const store of stores) {
|
|
61
|
+
try {
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
const role = classify(store.getItem(key));
|
|
64
|
+
if (role !== "unknown") return role;
|
|
65
|
+
}
|
|
66
|
+
} catch { /* storage can be disabled */ }
|
|
67
|
+
}
|
|
68
|
+
return "unknown";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function roleFromDom() {
|
|
72
|
+
const selectors = [
|
|
73
|
+
"[data-role]", "[data-user-role]", "[data-current-role]", "[aria-label*='Rolle' i]",
|
|
74
|
+
"[aria-label*='role' i]", "header", "nav", ".MuiAppBar-root"
|
|
75
|
+
];
|
|
76
|
+
for (const selector of selectors) {
|
|
77
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
78
|
+
const value = [
|
|
79
|
+
el.getAttribute && el.getAttribute("data-role"),
|
|
80
|
+
el.getAttribute && el.getAttribute("data-user-role"),
|
|
81
|
+
el.getAttribute && el.getAttribute("data-current-role"),
|
|
82
|
+
el.getAttribute && el.getAttribute("aria-label"),
|
|
83
|
+
el.textContent
|
|
84
|
+
].filter(Boolean).join(" ");
|
|
85
|
+
const role = classify(value);
|
|
86
|
+
if (role !== "unknown") return role;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findDisplayedUser() {
|
|
93
|
+
const candidates = document.querySelectorAll("header *, nav *, .MuiAppBar-root *");
|
|
94
|
+
for (const el of candidates) {
|
|
95
|
+
const text = normalize(el.textContent);
|
|
96
|
+
if (!text || text.length > 80) continue;
|
|
97
|
+
if (/\b(admin|installer|user|guest|gast|endkunde)\b/.test(text)) return text;
|
|
98
|
+
}
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function fetchSecurityStatus() {
|
|
103
|
+
try {
|
|
104
|
+
const user = encodeURIComponent(findDisplayedUser());
|
|
105
|
+
const response = await fetch(`/api/nexowatt-eos/security/status?user=${user}&v=${VERSION}`, {
|
|
106
|
+
credentials: "same-origin", cache: "no-store", headers: { "Accept": "application/json" }
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) return null;
|
|
109
|
+
return await response.json();
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAdmin() { return state.role === "admin"; }
|
|
116
|
+
function isCustomer() { return state.role === "customer"; }
|
|
117
|
+
|
|
118
|
+
function hide(el, reason) {
|
|
119
|
+
if (!el || el === document.body || el === document.documentElement) return;
|
|
120
|
+
if (!el.hasAttribute(HIDDEN)) {
|
|
121
|
+
el.setAttribute(HIDDEN, reason || "restricted");
|
|
122
|
+
el.setAttribute("aria-hidden", "true");
|
|
123
|
+
el.style.setProperty("display", "none", "important");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function unhide(el) {
|
|
128
|
+
if (!el || !el.hasAttribute(HIDDEN)) return;
|
|
129
|
+
el.removeAttribute(HIDDEN);
|
|
130
|
+
el.removeAttribute("aria-hidden");
|
|
131
|
+
el.style.removeProperty("display");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function closestAction(el) {
|
|
135
|
+
return el && el.closest && el.closest("a,button,[role='button'],[role='tab'],li,.MuiButtonBase-root") || el;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function includesAny(text, tokens) {
|
|
139
|
+
return tokens.some(token => text.includes(token));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function applyNavigationPolicy() {
|
|
143
|
+
const actions = document.querySelectorAll("a,button,[role='button'],[role='tab'],li,.MuiButtonBase-root");
|
|
144
|
+
for (const raw of actions) {
|
|
145
|
+
const el = closestAction(raw);
|
|
146
|
+
const text = textOf(el);
|
|
147
|
+
if (!text) continue;
|
|
148
|
+
|
|
149
|
+
const adminOnly = includesAny(text, adminOnlyTokens) ||
|
|
150
|
+
(/\badmin\b/.test(text) && /(?:8081|adapter=admin|io.?broker)/.test(text));
|
|
151
|
+
const backup = includesAny(text, backupTokens);
|
|
152
|
+
|
|
153
|
+
if (!isAdmin() && adminOnly) hide(el, "admin-only");
|
|
154
|
+
else if (adminOnly) unhide(el);
|
|
155
|
+
|
|
156
|
+
if (backup && state.backupActive === false) hide(el, "backup-inactive");
|
|
157
|
+
else if (backup && state.backupActive === true && (isAdmin() || !adminOnly)) unhide(el);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!isAdmin()) {
|
|
161
|
+
for (const el of document.querySelectorAll("[data-testid*='expert' i],[class*='expert' i],[id*='expert' i],[title*='Experten' i],[aria-label*='Experten' i],[title*='Expert' i],[aria-label*='Expert' i]")) {
|
|
162
|
+
hide(closestAction(el), "admin-only-expert");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isDataPointPage() {
|
|
168
|
+
const route = normalize(location.pathname + " " + location.hash + " " + document.title);
|
|
169
|
+
if (includesAny(route, dataPointTokens)) return true;
|
|
170
|
+
const selected = document.querySelector("[aria-selected='true'],.Mui-selected,.active");
|
|
171
|
+
return selected ? includesAny(textOf(selected), dataPointTokens) : false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeDatapointsReadOnly() {
|
|
175
|
+
const root = document.querySelector("main") || document.body;
|
|
176
|
+
if (!isCustomer() || !isDataPointPage()) {
|
|
177
|
+
root && root.removeAttribute(READONLY);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
root.setAttribute(READONLY, "true");
|
|
181
|
+
for (const el of root.querySelectorAll("input,textarea,select,[contenteditable='true'],button,[role='button']")) {
|
|
182
|
+
const text = textOf(el);
|
|
183
|
+
const isSearch = /filter|suche|search|refresh|aktualisieren|reload|sort|spalte|column|zurück|back/.test(text) ||
|
|
184
|
+
/search/i.test(el.getAttribute && el.getAttribute("type") || "") ||
|
|
185
|
+
/filter|search/i.test(el.getAttribute && el.getAttribute("placeholder") || "");
|
|
186
|
+
const likelyWriter = includesAny(text, dangerousWriteTokens) ||
|
|
187
|
+
/value|wert|write|edit|save|delete|setstate|state-value/i.test(text) ||
|
|
188
|
+
(el.matches("input,textarea,select,[contenteditable='true']") && !isSearch);
|
|
189
|
+
if (likelyWriter && !isSearch) {
|
|
190
|
+
el.setAttribute("aria-disabled", "true");
|
|
191
|
+
el.setAttribute("tabindex", "-1");
|
|
192
|
+
if ("disabled" in el) el.disabled = true;
|
|
193
|
+
if (el.getAttribute("contenteditable") === "true") el.setAttribute("contenteditable", "false");
|
|
194
|
+
el.style.setProperty("pointer-events", "none", "important");
|
|
195
|
+
el.style.setProperty("opacity", "0.62", "important");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function currentRouteRestricted() {
|
|
201
|
+
const route = normalize(location.pathname + " " + location.search + " " + location.hash);
|
|
202
|
+
return includesAny(route, adminOnlyTokens);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderForbidden() {
|
|
206
|
+
if (document.getElementById("eos-role-forbidden")) return;
|
|
207
|
+
const overlay = document.createElement("div");
|
|
208
|
+
overlay.id = "eos-role-forbidden";
|
|
209
|
+
overlay.innerHTML = `<div class="eos-role-forbidden-card"><strong>Zugriff gesperrt</strong><span>App-Center, Lizenz, Simulation und Expertenmodus sind ausschließlich für Administratoren freigegeben.</span><button type="button">Zurück zum Cockpit</button></div>`;
|
|
210
|
+
overlay.querySelector("button").addEventListener("click", () => {
|
|
211
|
+
location.hash = "#/";
|
|
212
|
+
try { history.replaceState(null, "", location.pathname); } catch { /* ignore */ }
|
|
213
|
+
overlay.remove();
|
|
214
|
+
});
|
|
215
|
+
document.body.appendChild(overlay);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function guardCurrentRoute() {
|
|
219
|
+
if (!isAdmin() && currentRouteRestricted()) renderForbidden();
|
|
220
|
+
else document.getElementById("eos-role-forbidden")?.remove();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearExpertMode() {
|
|
224
|
+
if (isAdmin()) return;
|
|
225
|
+
const keys = ["expertMode", "expert-mode", "eos.expert", "eosExpertMode", "adminExpertMode"];
|
|
226
|
+
for (const store of [window.localStorage, window.sessionStorage]) {
|
|
227
|
+
try { keys.forEach(key => store.removeItem(key)); } catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
window.__NEXOWATT_EXPERT_MODE__ = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function scrubPrivilegedUiSessions() {
|
|
233
|
+
if (isAdmin()) return;
|
|
234
|
+
const marker = "eos.nonAdminUiLogout.v7104";
|
|
235
|
+
try {
|
|
236
|
+
if (sessionStorage.getItem(marker)) return;
|
|
237
|
+
sessionStorage.setItem(marker, "1");
|
|
238
|
+
} catch { /* continue */ }
|
|
239
|
+
const protocol = location.protocol === "https:" ? "https:" : "http:";
|
|
240
|
+
const host = location.hostname;
|
|
241
|
+
for (const port of [8082, 8188]) {
|
|
242
|
+
const iframe = document.createElement("iframe");
|
|
243
|
+
iframe.hidden = true;
|
|
244
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
245
|
+
iframe.src = `${protocol}//${host}:${port}/logout?source=eos-role-security&v=${VERSION}`;
|
|
246
|
+
document.body.appendChild(iframe);
|
|
247
|
+
setTimeout(() => iframe.remove(), 12000);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function apply() {
|
|
252
|
+
state.appliedAt = Date.now();
|
|
253
|
+
document.documentElement.setAttribute("data-eos-effective-role", state.role);
|
|
254
|
+
applyNavigationPolicy();
|
|
255
|
+
makeDatapointsReadOnly();
|
|
256
|
+
clearExpertMode();
|
|
257
|
+
guardCurrentRoute();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function installEventGuards() {
|
|
261
|
+
document.addEventListener("click", event => {
|
|
262
|
+
if (isAdmin()) return;
|
|
263
|
+
const action = closestAction(event.target);
|
|
264
|
+
const text = textOf(action);
|
|
265
|
+
if (includesAny(text, adminOnlyTokens) || (/\badmin\b/.test(text) && /(?:8081|adapter=admin|io.?broker)/.test(text))) {
|
|
266
|
+
event.preventDefault(); event.stopImmediatePropagation(); renderForbidden(); return;
|
|
267
|
+
}
|
|
268
|
+
if (isCustomer() && isDataPointPage() && includesAny(text, dangerousWriteTokens)) {
|
|
269
|
+
event.preventDefault(); event.stopImmediatePropagation(); return;
|
|
270
|
+
}
|
|
271
|
+
}, true);
|
|
272
|
+
document.addEventListener("submit", event => {
|
|
273
|
+
if (isCustomer() && isDataPointPage()) { event.preventDefault(); event.stopImmediatePropagation(); }
|
|
274
|
+
}, true);
|
|
275
|
+
for (const name of ["pushState", "replaceState"]) {
|
|
276
|
+
const original = history[name];
|
|
277
|
+
history[name] = function(...args) {
|
|
278
|
+
const result = original.apply(this, args);
|
|
279
|
+
queueMicrotask(apply);
|
|
280
|
+
return result;
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
addEventListener("hashchange", apply);
|
|
284
|
+
addEventListener("popstate", apply);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function injectStyles() {
|
|
288
|
+
if (document.getElementById("eos-role-security-style")) return;
|
|
289
|
+
const style = document.createElement("style");
|
|
290
|
+
style.id = "eos-role-security-style";
|
|
291
|
+
style.textContent = `
|
|
292
|
+
[${HIDDEN}] { display:none !important; }
|
|
293
|
+
#eos-role-forbidden { position:fixed; inset:0; z-index:2147483647; display:grid; place-items:center; padding:24px; background:rgba(1,17,28,.96); color:#eefaff; font-family:Arial,sans-serif; }
|
|
294
|
+
.eos-role-forbidden-card { width:min(620px,100%); display:grid; gap:18px; padding:30px; border:1px solid #16d49d; border-radius:18px; background:#072333; box-shadow:0 20px 80px rgba(0,0,0,.45); }
|
|
295
|
+
.eos-role-forbidden-card strong { color:#24e6ad; font-size:28px; }
|
|
296
|
+
.eos-role-forbidden-card span { line-height:1.55; }
|
|
297
|
+
.eos-role-forbidden-card button { justify-self:start; padding:10px 18px; border:1px solid #16d49d; border-radius:10px; color:#021911; background:#24e6ad; font-weight:700; cursor:pointer; }
|
|
298
|
+
[${READONLY}="true"]::before { content:"Nur-Lesezugriff für Endkunden"; position:sticky; top:0; z-index:10; display:block; margin:8px; padding:8px 12px; border:1px solid #1aa57d; border-radius:9px; background:#07342b; color:#cffff0; font:600 13px Arial,sans-serif; }
|
|
299
|
+
`;
|
|
300
|
+
document.head.appendChild(style);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function init() {
|
|
304
|
+
injectStyles();
|
|
305
|
+
let role = roleFromGlobals();
|
|
306
|
+
if (role === "unknown") role = roleFromStorage();
|
|
307
|
+
if (role === "unknown") role = roleFromDom();
|
|
308
|
+
const status = await fetchSecurityStatus();
|
|
309
|
+
if (status) {
|
|
310
|
+
const serverRole = classify(status.role);
|
|
311
|
+
if (serverRole !== "unknown") role = serverRole;
|
|
312
|
+
if (typeof status.backupActive === "boolean") state.backupActive = status.backupActive;
|
|
313
|
+
state.user = status.user || "";
|
|
314
|
+
}
|
|
315
|
+
if (role === "unknown") role = "customer"; // least privilege until authenticated role is known
|
|
316
|
+
state.role = role;
|
|
317
|
+
try { sessionStorage.setItem("eos.role.effective", role); } catch { /* ignore */ }
|
|
318
|
+
installEventGuards();
|
|
319
|
+
apply();
|
|
320
|
+
scrubPrivilegedUiSessions();
|
|
321
|
+
const observer = new MutationObserver(() => {
|
|
322
|
+
clearTimeout(observer._timer);
|
|
323
|
+
observer._timer = setTimeout(apply, 40);
|
|
324
|
+
});
|
|
325
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "aria-selected", "href"] });
|
|
326
|
+
setInterval(async () => {
|
|
327
|
+
const refreshed = await fetchSecurityStatus();
|
|
328
|
+
if (refreshed && typeof refreshed.backupActive === "boolean" && refreshed.backupActive !== state.backupActive) {
|
|
329
|
+
state.backupActive = refreshed.backupActive; apply();
|
|
330
|
+
}
|
|
331
|
+
}, 10000);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
335
|
+
else void init();
|
|
336
|
+
})();
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NexoWatt EOS role security boundary.
|
|
5
|
+
*
|
|
6
|
+
* This module intentionally uses only Node.js core APIs so the prebuilt
|
|
7
|
+
* repository can be packed and published without a local node_modules folder.
|
|
8
|
+
*/
|
|
9
|
+
const http = require("node:http");
|
|
10
|
+
const https = require("node:https");
|
|
11
|
+
const { URL } = require("node:url");
|
|
12
|
+
|
|
13
|
+
let adapterRef = null;
|
|
14
|
+
let installed = false;
|
|
15
|
+
let backupCache = { value: false, expires: 0 };
|
|
16
|
+
|
|
17
|
+
const ADMIN_NAMES = new Set(["admin", "administrator", "system.user.admin"]);
|
|
18
|
+
const INSTALLER_TOKENS = ["installer", "installateur", "inbetriebnahme"];
|
|
19
|
+
const CUSTOMER_TOKENS = ["user", "guest", "gast", "endkunde", "customer", "smarthome"];
|
|
20
|
+
|
|
21
|
+
const ADMIN_ONLY_PATHS = [
|
|
22
|
+
/(?:^|[\\/#?&_.-])app[\\s_.-]*center(?:$|[\\/#?&_.-])/i,
|
|
23
|
+
/(?:^|[\\/#?&_.-])lizenz(?:en)?(?:$|[\\/#?&_.-])/i,
|
|
24
|
+
/(?:^|[\\/#?&_.-])licen[cs]e(?:s)?(?:$|[\\/#?&_.-])/i,
|
|
25
|
+
/(?:^|[\\/#?&_.-])simulation(?:$|[\\/#?&_.-])/i,
|
|
26
|
+
/(?:^|[\\/#?&_.-])simulator(?:$|[\\/#?&_.-])/i,
|
|
27
|
+
/(?:^|[\\/#?&_.-])experten?(?:modus)?(?:$|[\\/#?&_.-])/i,
|
|
28
|
+
/(?:^|[\\/#?&_.-])expert(?:mode)?(?:$|[\\/#?&_.-])/i,
|
|
29
|
+
/(?:^|[\\/#?&_.-])xterm(?:$|[\\/#?&_.-])/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const RIGHTS_PATHS = [
|
|
33
|
+
/(?:^|[\\/#?&_.-])users?(?:$|[\\/#?&_.-])/i,
|
|
34
|
+
/(?:^|[\\/#?&_.-])groups?(?:$|[\\/#?&_.-])/i,
|
|
35
|
+
/zug[aä]nge/i,
|
|
36
|
+
/rechte/i,
|
|
37
|
+
/permissions?/i,
|
|
38
|
+
/password/i,
|
|
39
|
+
/passwort/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function normalize(value) {
|
|
43
|
+
if (value === null || value === undefined) return "";
|
|
44
|
+
if (typeof value === "object") {
|
|
45
|
+
if (typeof value.name === "string") return value.name.trim().toLowerCase();
|
|
46
|
+
if (typeof value.user === "string") return value.user.trim().toLowerCase();
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
return String(value).trim().toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function classifyRole(value) {
|
|
53
|
+
const text = normalize(value);
|
|
54
|
+
if (!text) return "unknown";
|
|
55
|
+
if (ADMIN_NAMES.has(text) || /(?:^|[.:/_-])admin(?:istrator)?(?:$|[.:/_-])/.test(text)) return "admin";
|
|
56
|
+
if (INSTALLER_TOKENS.some(token => text.includes(token))) return "installer";
|
|
57
|
+
if (CUSTOMER_TOKENS.some(token => text === token || text.includes(token))) return "customer";
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseBasicUser(header) {
|
|
62
|
+
if (!header || !/^basic\s+/i.test(header)) return "";
|
|
63
|
+
try {
|
|
64
|
+
const decoded = Buffer.from(header.replace(/^basic\s+/i, ""), "base64").toString("utf8");
|
|
65
|
+
return decoded.split(":", 1)[0] || "";
|
|
66
|
+
} catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCookieHeader(header) {
|
|
72
|
+
const out = Object.create(null);
|
|
73
|
+
String(header || "").split(";").forEach(part => {
|
|
74
|
+
const idx = part.indexOf("=");
|
|
75
|
+
if (idx <= 0) return;
|
|
76
|
+
const key = part.slice(0, idx).trim();
|
|
77
|
+
let value = part.slice(idx + 1).trim();
|
|
78
|
+
try { value = decodeURIComponent(value); } catch { /* ignore */ }
|
|
79
|
+
out[key] = value;
|
|
80
|
+
});
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractIdentity(req) {
|
|
85
|
+
const candidates = [];
|
|
86
|
+
if (req) {
|
|
87
|
+
candidates.push(req.user, req.username, req.auth && req.auth.user, req.session && req.session.user,
|
|
88
|
+
req.session && req.session.username, req.session && req.session.passport && req.session.passport.user,
|
|
89
|
+
req.ioBrokerUser, req._user);
|
|
90
|
+
const headers = req.headers || {};
|
|
91
|
+
candidates.push(headers["x-iobroker-user"], headers["x-eos-user"], headers["x-authenticated-user"],
|
|
92
|
+
headers["x-forwarded-user"], parseBasicUser(headers.authorization));
|
|
93
|
+
const cookies = parseCookieHeader(headers.cookie);
|
|
94
|
+
for (const key of ["iobroker.user", "iobroker_user", "eos.user", "eosUser", "user", "username", "authUser"]) {
|
|
95
|
+
candidates.push(cookies[key]);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(req.url || "/", "http://127.0.0.1");
|
|
99
|
+
candidates.push(parsed.searchParams.get("user"), parsed.searchParams.get("username"));
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
for (const value of candidates) {
|
|
103
|
+
if (normalize(value)) return normalize(value);
|
|
104
|
+
}
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function roleFromRequest(req) {
|
|
109
|
+
const explicitRole = normalize(req && req.headers && (req.headers["x-eos-role"] || req.headers["x-nexowatt-role"]));
|
|
110
|
+
const role = classifyRole(explicitRole);
|
|
111
|
+
if (role !== "unknown") return role;
|
|
112
|
+
return classifyRole(extractIdentity(req));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAdminOnlyPath(rawUrl) {
|
|
116
|
+
const value = String(rawUrl || "");
|
|
117
|
+
return ADMIN_ONLY_PATHS.some(pattern => pattern.test(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isRightsPath(rawUrl) {
|
|
121
|
+
const value = String(rawUrl || "");
|
|
122
|
+
return RIGHTS_PATHS.some(pattern => pattern.test(value));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isStateWriteRequest(req) {
|
|
126
|
+
const method = String(req && req.method || "GET").toUpperCase();
|
|
127
|
+
if (!["POST", "PUT", "PATCH", "DELETE"].includes(method)) return false;
|
|
128
|
+
const value = String(req && req.url || "");
|
|
129
|
+
return /(?:setstate|write|objects?\/|states?\/|sendto|command)/i.test(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function deny(res, message, statusCode = 403) {
|
|
133
|
+
if (!res || res.headersSent || res.writableEnded) return true;
|
|
134
|
+
const accepts = String((res.req && res.req.headers && res.req.headers.accept) || "");
|
|
135
|
+
const json = accepts.includes("application/json") || String(res.req && res.req.url || "").includes("/api/");
|
|
136
|
+
res.statusCode = statusCode;
|
|
137
|
+
res.setHeader("Cache-Control", "no-store");
|
|
138
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
139
|
+
if (json) {
|
|
140
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
141
|
+
res.end(JSON.stringify({ ok: false, error: "forbidden", message }));
|
|
142
|
+
} else {
|
|
143
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
144
|
+
res.end(`<!doctype html><html lang="de"><meta charset="utf-8"><title>Zugriff gesperrt</title><style>body{margin:0;background:#031724;color:#eaf6ff;font:16px Arial,sans-serif;display:grid;place-items:center;min-height:100vh}.box{max-width:620px;padding:32px;border:1px solid #0dbb87;border-radius:18px;background:#071f2f}h1{margin-top:0;color:#24e6ad}</style><div class="box"><h1>Zugriff gesperrt</h1><p>${message}</p></div></html>`);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function getBackupActive() {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
if (backupCache.expires > now) return backupCache.value;
|
|
152
|
+
let active = false;
|
|
153
|
+
const adapter = adapterRef;
|
|
154
|
+
if (adapter) {
|
|
155
|
+
try {
|
|
156
|
+
let rows = [];
|
|
157
|
+
if (typeof adapter.getObjectViewAsync === "function") {
|
|
158
|
+
const result = await adapter.getObjectViewAsync("system", "instance", { startkey: "system.adapter.", endkey: "system.adapter.\u9999" });
|
|
159
|
+
rows = result && Array.isArray(result.rows) ? result.rows : [];
|
|
160
|
+
} else if (typeof adapter.getObjectListAsync === "function") {
|
|
161
|
+
const result = await adapter.getObjectListAsync({ startkey: "system.adapter.", endkey: "system.adapter.\u9999" });
|
|
162
|
+
rows = result && Array.isArray(result.rows) ? result.rows : [];
|
|
163
|
+
}
|
|
164
|
+
active = rows.some(row => {
|
|
165
|
+
const obj = row && (row.value || row.doc);
|
|
166
|
+
const id = normalize(row && row.id || obj && obj._id);
|
|
167
|
+
return /system\.adapter\.(?:backitup|backup|nexowatt-backup|eos-backup)\./.test(id) && Boolean(obj && obj.common && obj.common.enabled);
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (adapter && adapter.log && typeof adapter.log.debug === "function") {
|
|
171
|
+
adapter.log.debug(`[role-security] Backup status could not be read: ${error && error.message || error}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
backupCache = { value: active, expires: now + 5000 };
|
|
176
|
+
return active;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function attachAdapter(adapter) {
|
|
180
|
+
adapterRef = adapter || null;
|
|
181
|
+
backupCache.expires = 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function cloneAclSection(section) {
|
|
185
|
+
return section && typeof section === "object" ? { ...section } : {};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function enforceRoleAcls(adapter = adapterRef) {
|
|
189
|
+
if (!adapter || typeof adapter.getForeignObjectAsync !== "function" || typeof adapter.setForeignObjectAsync !== "function") return;
|
|
190
|
+
const ids = ["system.group.endkunde"];
|
|
191
|
+
for (const id of ids) {
|
|
192
|
+
try {
|
|
193
|
+
const obj = await adapter.getForeignObjectAsync(id);
|
|
194
|
+
if (!obj || !obj.common) continue;
|
|
195
|
+
const acl = obj.common.acl && typeof obj.common.acl === "object" ? { ...obj.common.acl } : {};
|
|
196
|
+
const objectAcl = cloneAclSection(acl.object);
|
|
197
|
+
const stateAcl = cloneAclSection(acl.state);
|
|
198
|
+
const usersAcl = cloneAclSection(acl.users);
|
|
199
|
+
const fileAcl = cloneAclSection(acl.file);
|
|
200
|
+
const otherAcl = cloneAclSection(acl.other);
|
|
201
|
+
const before = JSON.stringify({ objectAcl, stateAcl, usersAcl, fileAcl, otherAcl });
|
|
202
|
+
Object.assign(objectAcl, { list: true, read: true, write: false, delete: false });
|
|
203
|
+
Object.assign(stateAcl, { list: true, read: true, write: false, delete: false, create: false });
|
|
204
|
+
Object.assign(usersAcl, { write: false, delete: false, create: false });
|
|
205
|
+
Object.assign(fileAcl, { list: true, read: true, write: false, delete: false, create: false });
|
|
206
|
+
Object.assign(otherAcl, { execute: false, sendto: false });
|
|
207
|
+
acl.object = objectAcl;
|
|
208
|
+
acl.state = stateAcl;
|
|
209
|
+
acl.users = usersAcl;
|
|
210
|
+
acl.file = fileAcl;
|
|
211
|
+
acl.other = otherAcl;
|
|
212
|
+
const after = JSON.stringify({ objectAcl, stateAcl, usersAcl, fileAcl, otherAcl });
|
|
213
|
+
if (before !== after) {
|
|
214
|
+
obj.common.acl = acl;
|
|
215
|
+
await adapter.setForeignObjectAsync(id, obj);
|
|
216
|
+
if (adapter.log && typeof adapter.log.info === "function") {
|
|
217
|
+
adapter.log.info("[role-security] End customer group enforced as read-only for objects and states");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (adapter.log && typeof adapter.log.warn === "function") {
|
|
222
|
+
adapter.log.warn(`[role-security] ACL enforcement failed for ${id}: ${error && error.message || error}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function handleStatus(req, res) {
|
|
229
|
+
const role = roleFromRequest(req);
|
|
230
|
+
const backupActive = await getBackupActive();
|
|
231
|
+
res.statusCode = 200;
|
|
232
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
233
|
+
res.setHeader("Cache-Control", "no-store");
|
|
234
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
235
|
+
res.end(JSON.stringify({
|
|
236
|
+
ok: true,
|
|
237
|
+
role,
|
|
238
|
+
user: extractIdentity(req) || null,
|
|
239
|
+
backupActive,
|
|
240
|
+
capabilities: {
|
|
241
|
+
appCenter: role === "admin",
|
|
242
|
+
license: role === "admin",
|
|
243
|
+
simulation: role === "admin",
|
|
244
|
+
expertMode: role === "admin",
|
|
245
|
+
accessRights: role === "admin",
|
|
246
|
+
xterm: role === "admin",
|
|
247
|
+
ioBrokerAdmin: role === "admin",
|
|
248
|
+
writeDatapoints: role === "admin" || role === "installer",
|
|
249
|
+
readDatapoints: true,
|
|
250
|
+
},
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function wrapListener(listener) {
|
|
255
|
+
if (typeof listener !== "function") return listener;
|
|
256
|
+
return function guardedListener(req, res) {
|
|
257
|
+
try {
|
|
258
|
+
const url = String(req && req.url || "/");
|
|
259
|
+
if (/^\/api\/nexowatt-eos\/security\/(?:status|me)(?:[/?#]|$)/i.test(url)) {
|
|
260
|
+
void handleStatus(req, res);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const role = roleFromRequest(req);
|
|
264
|
+
if (role !== "unknown" && role !== "admin" && (isAdminOnlyPath(url) || isRightsPath(url))) {
|
|
265
|
+
deny(res, "Diese Funktion ist ausschließlich für Administratoren freigegeben.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (role === "customer" && isStateWriteRequest(req)) {
|
|
269
|
+
deny(res, "Endkunden besitzen für Datenpunkte ausschließlich Leserechte.");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Fail closed only for positively identified restricted users. Other
|
|
274
|
+
// requests continue to the existing server so startup is never blocked.
|
|
275
|
+
}
|
|
276
|
+
return listener.apply(this, arguments);
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function patchCreateServer(module) {
|
|
281
|
+
const original = module.createServer;
|
|
282
|
+
if (typeof original !== "function" || original.__nexowattRoleSecurity) return;
|
|
283
|
+
function secureCreateServer(...args) {
|
|
284
|
+
if (typeof args[0] === "function") args[0] = wrapListener(args[0]);
|
|
285
|
+
else if (typeof args[1] === "function") args[1] = wrapListener(args[1]);
|
|
286
|
+
return original.apply(this, args);
|
|
287
|
+
}
|
|
288
|
+
Object.defineProperty(secureCreateServer, "__nexowattRoleSecurity", { value: true });
|
|
289
|
+
module.createServer = secureCreateServer;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function installHttpGuard() {
|
|
293
|
+
if (installed) return;
|
|
294
|
+
installed = true;
|
|
295
|
+
patchCreateServer(http);
|
|
296
|
+
patchCreateServer(https);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
attachAdapter,
|
|
301
|
+
classifyRole,
|
|
302
|
+
enforceRoleAcls,
|
|
303
|
+
extractIdentity,
|
|
304
|
+
getBackupActive,
|
|
305
|
+
installHttpGuard,
|
|
306
|
+
isAdminOnlyPath,
|
|
307
|
+
isRightsPath,
|
|
308
|
+
roleFromRequest,
|
|
309
|
+
wrapListener,
|
|
310
|
+
};
|
package/build/main.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
|
|
3
|
+
const eosRoleSecurity = require("./lib/eosRoleSecurity");
|
|
4
|
+
eosRoleSecurity.installHttpGuard();
|
|
2
5
|
/**
|
|
3
6
|
* Admin backend
|
|
4
7
|
*
|
|
@@ -329,6 +332,8 @@ class Admin extends adapter_core_1.Adapter {
|
|
|
329
332
|
* Is called when databases are connected and adapter received configuration.
|
|
330
333
|
*/
|
|
331
334
|
onReady = async () => {
|
|
335
|
+
eosRoleSecurity.attachAdapter(this);
|
|
336
|
+
await eosRoleSecurity.enforceRoleAcls(this);
|
|
332
337
|
const systemConfig = await this.getForeignObjectAsync('system.config');
|
|
333
338
|
if (systemConfig) {
|
|
334
339
|
systemConfig.native = systemConfig.native || {};
|