From cfee9d0c199de422553e3e14de6f6b68d4cd3a2b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 6 Jul 2026 13:22:44 +0300 Subject: [PATCH] Protect CMS deploy password field --- admin/admin.css | 40 ++++++++++++++++++++++++++++++++++++++ admin/admin.js | 43 ++++++++++++++++++++++++++++++++++++++--- server/admin-server.mjs | 13 ++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/admin/admin.css b/admin/admin.css index 0dd407a..acda3b7 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -970,6 +970,46 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form { grid-column: 1 / -1; } +.secret-input-wrap { + position: relative; + display: grid; + min-width: 0; +} + +.secret-input-wrap input { + padding-right: 3rem; +} + +.secret-toggle-button { + position: absolute; + top: 50%; + right: 0.42rem; + display: grid; + width: 2.1rem; + height: 2.1rem; + place-items: center; + border: 0; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.065); + color: rgba(255, 255, 255, 0.72); + transform: translateY(-50%); +} + +.secret-toggle-button:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); +} + +.secret-toggle-button svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + .field-label-row { display: flex; min-width: 0; diff --git a/admin/admin.js b/admin/admin.js index 9863346..88d1e2c 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -640,6 +640,14 @@ function syncProjectConfigFromForm() { async function saveProjectSettings() { syncProjectConfigFromForm(); + const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim(); + if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) { + setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD"); + renderProjectSettings(); + setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD."); + return; + } + const response = await fetch("/api/project/config", { method: "PUT", headers: { "content-type": "application/json" }, @@ -1932,6 +1940,7 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti const badge = document.createElement("span"); const control = options.length ? document.createElement("select") : document.createElement("input"); const value = projectConfigValue(path); + const isSecret = type === "password"; field.className = type === "checkbox" ? "toggle-row" : "field-control"; title.textContent = label; @@ -1956,8 +1965,30 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti } badge.className = "field-kind"; - badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : "TEXT"; - field.append(title, badge, control); + badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : isSecret ? "SECRET" : "TEXT"; + field.append(title, badge); + + if (isSecret) { + const secretWrap = document.createElement("span"); + const reveal = document.createElement("button"); + secretWrap.className = "secret-input-wrap"; + reveal.className = "secret-toggle-button"; + reveal.type = "button"; + reveal.innerHTML = + ''; + reveal.setAttribute("aria-label", "Показать значение"); + reveal.title = "Показать значение"; + reveal.addEventListener("click", () => { + const visible = control.type === "text"; + control.type = visible ? "password" : "text"; + reveal.setAttribute("aria-label", visible ? "Показать значение" : "Скрыть значение"); + reveal.title = visible ? "Показать значение" : "Скрыть значение"; + }); + secretWrap.append(control, reveal); + field.append(secretWrap); + } else { + field.append(control); + } } control.dataset.projectPath = path; @@ -2113,7 +2144,13 @@ function renderProjectSettings() { createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }), createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }), createProjectField({ path: "deploy.username", label: "Логин" }), - createProjectField({ path: "deploy.passwordEnv", label: "Переменная пароля", placeholder: "MCHOST_PASSWORD" }), + createProjectField({ + path: "deploy.passwordEnv", + label: "Имя переменной пароля", + type: "password", + placeholder: "MCHOST_PASSWORD", + hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.", + }), createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }), ], }); diff --git a/server/admin-server.mjs b/server/admin-server.mjs index 55d4570..06a2bf4 100644 --- a/server/admin-server.mjs +++ b/server/admin-server.mjs @@ -215,6 +215,11 @@ function trailingSlashUrl(value) { return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; } +function normalizeSecretEnvName(value, fallback = "MCHOST_PASSWORD") { + const trimmed = String(value || "").trim(); + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) ? trimmed : fallback; +} + function fileReady(filePath) { try { return statSync(filePath).isFile(); @@ -809,8 +814,14 @@ function authorizeRequest(req, res, url) { } function publicProjectConfig() { + const deploy = { + ...(projectConfig.deploy || {}), + passwordEnv: normalizeSecretEnvName(projectConfig.deploy?.passwordEnv), + }; + return { ...projectConfig, + deploy, id: projectConfig.id || activeProjectId, runtime: { cmsRoot, @@ -829,7 +840,7 @@ function normalizeProjectConfig(payload) { host: String(payload?.deploy?.host || "").trim(), port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)), username: String(payload?.deploy?.username || "").trim(), - passwordEnv: String(payload?.deploy?.passwordEnv || "").trim(), + passwordEnv: normalizeSecretEnvName(payload?.deploy?.passwordEnv), remotePath: String(payload?.deploy?.remotePath || "").trim(), deployOnRender: Boolean(payload?.deploy?.deployOnRender), };