test(auth): зафиксировать контракт авторизации в smoke-тесте
Документация долго описывала X-Admin-Token как способ авторизации, хотя его нет в коде. Расхождение не ловилось ничем: curl-пример в доках ходил на GET /api/groups, а он публичный (optionalAuth) и отвечает 200 без токена, то есть авторизацию не проверял вообще. Добавлены проверки, которые падают при возврате статического токена: - защищённый маршрут без токена -> 401; - мусорный X-Auth-Token -> 401; - X-Admin-Token (значение ADMIN_PASSWORD) -> 401; - Authorization: Bearer -> 401; - ADMIN_PASSWORD как токен -> 401; - позитивный контроль: валидный токен на admin-маршруте -> 200, иначе проверки выше проходили бы из-за сломанного роута; - /api/groups остаётся публичным -> 200 без токена. Хелпер api() научен принимать произвольные заголовки — иначе X-Admin-Token и Bearer не отправить. AGENTS.md дополнен описанием контракта и пунктом в чеклисте безопасности.
This commit is contained in:
@@ -179,6 +179,12 @@ docker compose start redis # app reconnects on its
|
|||||||
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
||||||
`fallbackOps`, `used_memory_human`, `keys`).
|
`fallbackOps`, `used_memory_human`, `keys`).
|
||||||
|
|
||||||
|
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
|
||||||
|
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
|
||||||
|
token must all be rejected with 401. If you change the auth scheme, update this test and the
|
||||||
|
Auth notes in this file together — a doc that drifts from the code is the failure mode this
|
||||||
|
guards against.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Checklist (before any change)
|
## Security Checklist (before any change)
|
||||||
@@ -187,6 +193,7 @@ Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`
|
|||||||
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||||
- [ ] Rate limiter on new public routes
|
- [ ] Rate limiter on new public routes
|
||||||
- [ ] Admin routes behind `requireAdmin`
|
- [ ] Admin routes behind `requireAdmin`
|
||||||
|
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
|
||||||
- [ ] No secrets in code — only via env vars
|
- [ ] No secrets in code — only via env vars
|
||||||
- [ ] Helmet headers present (already global)
|
- [ ] Helmet headers present (already global)
|
||||||
- [ ] CORS disabled (no `cors` middleware)
|
- [ ] CORS disabled (no `cors` middleware)
|
||||||
|
|||||||
+24
-1
@@ -11,10 +11,11 @@ function loadEnv() {
|
|||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:3003';
|
const BASE = process.env.BASE || 'http://localhost:3003';
|
||||||
|
|
||||||
async function api(pathname, { token, method = 'GET', body } = {}) {
|
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (token) headers['X-Auth-Token'] = token;
|
if (token) headers['X-Auth-Token'] = token;
|
||||||
if (body) headers['Content-Type'] = 'application/json';
|
if (body) headers['Content-Type'] = 'application/json';
|
||||||
|
Object.assign(headers, extra || {});
|
||||||
const res = await fetch(BASE + pathname, {
|
const res = await fetch(BASE + pathname, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
@@ -46,6 +47,28 @@ async function main() {
|
|||||||
const me1 = await api('/api/auth/me', { token });
|
const me1 = await api('/api/auth/me', { token });
|
||||||
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
|
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
|
||||||
|
|
||||||
|
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
|
||||||
|
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
|
||||||
|
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
|
||||||
|
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
|
||||||
|
// где он задаётся через login.
|
||||||
|
const PROTECTED = '/api/auth/me';
|
||||||
|
const ADMIN_ONLY = '/api/users';
|
||||||
|
const noAuth = await api(PROTECTED);
|
||||||
|
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
|
||||||
|
const garbage = await api(PROTECTED, { token: 'deadbeef' });
|
||||||
|
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
|
||||||
|
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
|
||||||
|
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
|
||||||
|
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
|
||||||
|
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
|
||||||
|
const passAsToken = await api(PROTECTED, { token: pass });
|
||||||
|
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
|
||||||
|
const positive = await api(ADMIN_ONLY, { token });
|
||||||
|
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
|
||||||
|
const publicNoAuth = await api('/api/groups');
|
||||||
|
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
|
||||||
|
|
||||||
const groups = await api('/api/groups', { token });
|
const groups = await api('/api/groups', { token });
|
||||||
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
|
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user