diff --git a/apps/web/e2e/server-management.spec.ts b/apps/web/e2e/server-management.spec.ts index d22ed8f..3d5b96e 100644 --- a/apps/web/e2e/server-management.spec.ts +++ b/apps/web/e2e/server-management.spec.ts @@ -63,6 +63,54 @@ test('update delegate can update but cannot open configuration', async ({ page } await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible(); }); +test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => { + await login(page); + await page.evaluate(() => { + const original = window.fetch; + window.fetch = async (input, init) => { + const response = await original(input, init); + const request = new Request(input, init); + if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') { + const data = await response.json(); + const common = { group: 'api', type: 'string', secret: true, readonly: false }; + data.entries = [ + { ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' }, + { ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' }, + { ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' }, + ]; + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + } + return response; + }; + history.pushState({}, '', '/admin/config'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + const env = page.getByLabel('ENV_SECRET · config.local', { exact: true }); + const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true }); + await expect(env).toHaveValue(''); + await expect(env).toHaveAttribute('type', 'password'); + await expect(defaults).toHaveValue(''); + await expect(defaults).toHaveAttribute('placeholder', ''); + await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible(); + for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) { + expect(await page.getByRole('main').innerHTML()).not.toContain(canary); + } + await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click(); + await expect(env).toHaveValue(''); +}); + +test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => { + await login(page, 'mobile-admin', '/admin/config'); + const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true }); + await token.fill('replacement-canary'); + await expect(token).toHaveAttribute('type', 'password'); + await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); + await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible(); + await expect(token).toHaveValue(''); + await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён'); + expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary'); +}); + test('ordinary user is redirected away from server management', async ({ page }) => { await login(page, 'alice', '/admin/updates'); await expect(page).toHaveURL('/'); diff --git a/apps/web/src/features/server-management/ConfigPage.tsx b/apps/web/src/features/server-management/ConfigPage.tsx index e80b5f3..c676441 100644 --- a/apps/web/src/features/server-management/ConfigPage.tsx +++ b/apps/web/src/features/server-management/ConfigPage.tsx @@ -66,11 +66,12 @@ export function ConfigPage() {
{entries.map((entry) => { const changed = Object.hasOwn(draft, entry.key); const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? ''; + const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored; return
{entry.key}{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}{t(`management.value_source.${entry.source}`)}
-
{t('management.effective')}: {entry.effective || '—'}ENV: {entry.env ?? '—'}
+
{t('management.effective')}: {display(entry.effective) || '—'}ENV: {display(entry.env) ?? '—'}
{entry.readonly ?

{t('management.readonly')}

:
- change(entry.key, v)} mono /> + change(entry.key, v)} mono />
}
; diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 34af4e6..86cb46e 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -90,7 +90,8 @@ export function fakeApi(initial: FakeUser[]) { { key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' }, ]; const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => { - const local = configValues[entry.key] ?? entry.local; + const rawLocal = configValues[entry.key] ?? entry.local; + const local = entry.secret && rawLocal ? '••••••••' : rawLocal; return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' }; }) }); const me = (u: FakeUser): Me => ({ diff --git a/deploy/config.schema.json b/deploy/config.schema.json index 6693518..82be82d 100644 --- a/deploy/config.schema.json +++ b/deploy/config.schema.json @@ -92,7 +92,7 @@ "group": "api", "type": "duration", "default_value": "15m", - "secret": true, + "secret": false, "readonly": false }, { diff --git a/deploy/server-agent.py b/deploy/server-agent.py index c2c5d45..c7404dd 100644 --- a/deploy/server-agent.py +++ b/deploy/server-agent.py @@ -94,6 +94,18 @@ def now(): return datetime.now(timezone.utc).isoformat() +def sensitive_url(key, value): + """URL credentials also occur in userinfo, query strings and fragments.""" + if not key.endswith("_URL") or not value: + return False + try: + parsed = urlsplit(value) + return bool(parsed.username or parsed.password or parsed.query or parsed.fragment) + except ValueError: + # A malformed URL must not bypass masking. + return True + + class Agent: def __init__(self, deploy, mailbox, private): self.deploy, self.mailbox, self.private = deploy, mailbox, private @@ -162,9 +174,12 @@ class Agent: key = item["key"] source = "env" if key in env else "local" if key in local else "default" effective = env.get(key, local.get(key, item["default_value"])) + secret = item["secret"] or any(sensitive_url(key, value) for value in + (env.get(key), local.get(key), item["default_value"])) def display(value): - return "••••••••" if item["secret"] and value else value - entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)), + return "••••••••" if secret and value else value + entries.append({**item, "secret": secret, "default_value": display(item["default_value"]), + "env": display(env.get(key)), "local": display(local.get(key)), "effective": display(effective), "source": source}) effective = self.effective_revision() applied = self.load("applied-effective.json", None) diff --git a/deploy/test_server_agent.py b/deploy/test_server_agent.py index 66b628c..52af19a 100644 --- a/deploy/test_server_agent.py +++ b/deploy/test_server_agent.py @@ -113,6 +113,52 @@ class AgentTests(unittest.TestCase): self.agent.save_config({"revision": result["revision"], "values": {"TRAIN_HOUR": None}}) self.assertNotIn("TRAIN_HOUR", agent_module.read_env(self.deploy / "config.local")) + def test_secret_defaults_and_shadowed_values_never_leave_worker(self): + self.schema[1]["default_value"] = "default-canary" + (self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8") + (self.deploy / "config.local").write_text("GITEA_TOKEN=local-canary\n", encoding="utf-8") + config = self.agent.config() + encoded = json.dumps(config) + for value in ("top-secret", "local-canary", "default-canary"): + self.assertNotIn(value, encoded) + token = next(entry for entry in config["entries"] if entry["key"] == "GITEA_TOKEN") + for field in ("env", "local", "effective", "default_value"): + self.assertEqual("••••••••", token[field]) + + def test_real_catalog_masks_every_secret_and_credential_bearing_url(self): + schema = json.loads(Path(__file__).with_name("config.schema.json").read_text(encoding="utf-8")) + (self.deploy / "config.schema.json").write_text(json.dumps(schema), encoding="utf-8") + secrets = {item["key"] for item in schema if item["secret"]} + self.assertTrue({"POSTGRES_PASSWORD", "DATABASE_URL", "REDIS_URL", "JWT_PRIVATE_KEY", + "JWT_PREVIOUS_KEYS", "BOOTSTRAP_ADMIN_PASSWORD", "INTERNAL_TOKEN", + "STREAM_SIGNING_KEY", "GENIUS_ACCESS_TOKEN", "MEILI_MASTER_KEY", "GITEA_TOKEN"} <= secrets) + self.assertNotIn("ACCESS_TOKEN_TTL", secrets) + env = {key: f"env-canary-{key}" for key in secrets} + local = {key: f"local-canary-{key}" for key in secrets} + env.update(GITEA_URL="https://user:password@example.org", MEILI_URL="https://example.org?api_key=query-canary") + local.update(GITEA_URL="https://example.org#fragment-canary", MEILI_URL="https://token@example.org") + for name, values in ((".env", env), ("config.local", local)): + (self.deploy / name).write_text("\n".join(f"{key}={value}" for key, value in values.items()), encoding="utf-8") + config = self.agent.config() + for value in (*env.values(), *local.values()): + self.assertNotIn(value, json.dumps(config)) + entries = {entry["key"]: entry for entry in config["entries"]} + for key in secrets | {"GITEA_URL", "MEILI_URL"}: + self.assertTrue(entries[key]["secret"]) + self.assertEqual("••••••••", entries[key]["effective"]) + self.assertEqual("15m", entries["ACCESS_TOKEN_TTL"]["effective"]) + + def test_plain_urls_remain_readable_and_local_credentials_are_masked_after_save(self): + self.schema.append({**self.schema[0], "key": "MEILI_URL", "default_value": "https://example.org/search"}) + (self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8") + entry = self.agent.config()["entries"][-1] + self.assertFalse(entry["secret"]) + self.assertEqual("https://example.org/search", entry["effective"]) + with patch.object(self.agent, "command", return_value=""): + config = self.agent.save_config({"revision": self.agent.revision(), "values": {"MEILI_URL": "https://user:save-canary@example.org"}}) + self.assertNotIn("save-canary", json.dumps(config)) + self.assertTrue(config["entries"][-1]["secret"]) + def test_invalid_values_and_compose_failure_preserve_original(self): original = (self.deploy / "config.local").read_bytes() for values in ({"TRAIN_HOUR": "24"}, {"TRAIN_HOUR": "bad"}, {"UNKNOWN": "x"}, {"PIPU_DATA": "/tmp"}, {"LOG_LEVEL": "x\nOTHER=y"}): diff --git a/docs/server-management.md b/docs/server-management.md index 05d3789..456d359 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -127,6 +127,11 @@ API, общие параметры сервисов, рекомендатель открытым текстом: поле без изменений сохраняет прежний секрет, ввод заменяет, **Сбросить локальное** удаляет только значение из config.local. +Маскирование охватывает `.env`, config.local, итоговое значение и значение по +умолчанию. URL со встроенными учётными данными, query-параметрами или фрагментом +целиком считается секретом. Поля замены изначально пусты; переключатель видимости +показывает только новый ввод. После сохранения поле снова очищается. + Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт правки другого администратора. Перед сохранением проверяются типы и Compose; при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк