fix(admin): mask all stored configuration secrets and credential URLs
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m15s
ci / ts / ts (packages + web) (push) Successful in 2m19s

This commit is contained in:
mrleo1nid committed 2026-10-23 00:27:20 +03:00
1 parent a6c9fd8a00
commit 24707a0bfc
7 files changed
+122 -6

No files matched your search

+48
View File
@@ -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>;
+2 -1
View File
@@ -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 => ({