fix(admin): mask all stored configuration secrets and credential URLs
This commit is contained in:
1 parent
a6c9fd8a00
commit
24707a0bfc
7 files changed
+122
-6
No files matched your search
@@ -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('/');
|
||||
|
||||
@@ -66,11 +66,12 @@ export function ConfigPage() {
|
||||
<div className={s.configList}>{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 <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
||||
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
||||
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
|
||||
<div className={s.values}><span>{t('management.effective')}: <code>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(entry.env) ?? '—'}</code></span></div>
|
||||
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
||||
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} autoComplete={entry.secret ? 'new-password' : 'off'} value={value} disabled={running || save.isPending} placeholder={entry.secret ? entry.local ? t('management.secret_saved') : '' : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
||||
</div>}
|
||||
</section>;
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "15m",
|
||||
"secret": true,
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
|
||||
+17
-2
@@ -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)
|
||||
|
||||
@@ -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"}):
|
||||
|
||||
@@ -127,6 +127,11 @@ API, общие параметры сервисов, рекомендатель
|
||||
открытым текстом: поле без изменений сохраняет прежний секрет, ввод заменяет,
|
||||
**Сбросить локальное** удаляет только значение из config.local.
|
||||
|
||||
Маскирование охватывает `.env`, config.local, итоговое значение и значение по
|
||||
умолчанию. URL со встроенными учётными данными, query-параметрами или фрагментом
|
||||
целиком считается секретом. Поля замены изначально пусты; переключатель видимости
|
||||
показывает только новый ввод. После сохранения поле снова очищается.
|
||||
|
||||
Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт
|
||||
правки другого администратора. Перед сохранением проверяются типы и Compose;
|
||||
при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк
|
||||
|
||||
Reference in new issue
Block a user