test(e2e): scenario security-edge — auth-гейт, traversal, SQLi, tenant, CORS
6 шагов (ТЗ 2.17): защищённые эндпоинты без токена → 401; /health и /connect/token анонимны; path-traversal на /uploads (закодированные ../) не отдаёт файлы ФС; SQL-инъекция в quick-search не роняет и не меняет данные; товар чужого тенанта → 404 (не 403/200); CORS не отражает чужой Origin. Багов в этих областях нет. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a04b4bf2dd
commit
2fd3b6d75c
129
tests/e2e/scenarios/security-edge.steps.ts
Normal file
129
tests/e2e/scenarios/security-edge.steps.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Step-handlers для security-edge.
|
||||
*
|
||||
* Базовые проверки OWASP-поверхности на уровне API: обязательность auth,
|
||||
* path-traversal, SQL-инъекция, межтенантная утечка (404 vs 403), CORS.
|
||||
*/
|
||||
import { login, makeClient } from '../lib/api.js'
|
||||
import { generateEan13 } from '../lib/barcode.js'
|
||||
import { psql } from '../lib/db.js'
|
||||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||
import axios from 'axios'
|
||||
import { Agent as HttpsAgent } from 'node:https'
|
||||
|
||||
const TS = Date.now()
|
||||
const httpsAgent = new HttpsAgent({ rejectUnauthorized: false })
|
||||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://admin.food-market.kz'
|
||||
|
||||
interface Ctx { apiOnly: boolean; sa?: string; aToken?: string; aProductId?: string }
|
||||
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||
|
||||
function check(step: Step, c: CheckResult) { step.checks.push(c) }
|
||||
function q1(sql: string): string { return (psql(sql).trim().split('\n')[0] ?? '').trim() }
|
||||
const noAuth = () => makeClient()
|
||||
|
||||
async function ensureSa(ctx: Ctx) {
|
||||
if (!ctx.sa) ctx.sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
|
||||
return ctx.sa
|
||||
}
|
||||
|
||||
// Полноценная орг A с товаром (для tenant- и SQLi-проверок).
|
||||
async function bootstrapA(ctx: Ctx) {
|
||||
if (ctx.aToken) return
|
||||
const sa = await ensureSa(ctx)
|
||||
const org = await makeClient(sa).post('/api/super-admin/organizations', {
|
||||
org: { name: `Sec ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
|
||||
adminLastName: 'Sec', adminFirstName: 'Admin', adminEmail: `sec-${TS}@example.kz`, adminPosition: null,
|
||||
})
|
||||
ctx.aToken = (await login(org.data.adminEmail, org.data.adminTempPassword)).accessToken
|
||||
const api = makeClient(ctx.aToken)
|
||||
const unitId = (await api.get('/api/catalog/units-of-measure')).data?.items?.[0]?.id
|
||||
let groupId = (await api.get('/api/catalog/product-groups?pageSize=10')).data?.items?.[0]?.id
|
||||
if (!groupId) groupId = (await api.post('/api/catalog/product-groups', { name: 'G', parentId: null })).data?.id
|
||||
const cur = (await api.get('/api/catalog/currencies?pageSize=200')).data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
|
||||
const pt = (await api.get('/api/catalog/price-types')).data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
|
||||
const prod = await api.post('/api/catalog/products', {
|
||||
name: `SecProd ${TS}`, unitOfMeasureId: unitId, productGroupId: groupId, vat: 12, vatEnabled: true,
|
||||
barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: pt, currencyId: cur, amount: 100 }],
|
||||
})
|
||||
ctx.aProductId = prod.data?.id
|
||||
}
|
||||
|
||||
export async function step01_protected_require_auth({ step, report }: StepCtx) {
|
||||
const api = noAuth()
|
||||
const endpoints = [
|
||||
'/api/me', '/api/catalog/products', '/api/catalog/counterparties', '/api/organization/employees',
|
||||
'/api/inventory/stock', '/api/purchases/supplies', '/api/sales/retail', '/api/super-admin/organizations',
|
||||
]
|
||||
const leaks: string[] = []
|
||||
for (const e of endpoints) {
|
||||
const r = await api.get(e)
|
||||
if (r.status !== 401) leaks.push(`${e}=${r.status}`)
|
||||
}
|
||||
check(step, { kind: 'api', description: 'Все защищённые GET без токена → 401', ok: leaks.length === 0, detail: leaks.length ? `НЕ 401: ${leaks.join(', ')}` : `проверено ${endpoints.length}` })
|
||||
if (leaks.length) report.bug({ step: '01', severity: 'critical', title: 'Защищённый эндпоинт доступен без токена', detail: leaks.join(', ') })
|
||||
}
|
||||
|
||||
export async function step02_anonymous_open({ step }: StepCtx) {
|
||||
const h = await axios.get(`${BASE}/health`, { httpsAgent, validateStatus: () => true })
|
||||
check(step, { kind: 'api', description: '/health без токена → 200', ok: h.status === 200, detail: `status=${h.status}` })
|
||||
// /connect/token достижим анонимно (без креды → 400 invalid_grant, НЕ 401).
|
||||
const t = await axios.post(`${BASE}/connect/token`, new URLSearchParams({ grant_type: 'password', username: 'x@y.z', password: 'bad', client_id: 'food-market-web', scope: 'api' }),
|
||||
{ httpsAgent, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, validateStatus: () => true })
|
||||
check(step, { kind: 'api', description: '/connect/token анонимен (400, не 401)', ok: t.status === 400, detail: `status=${t.status}` })
|
||||
}
|
||||
|
||||
export async function step03_path_traversal_uploads({ step, report }: StepCtx) {
|
||||
// Шлём закодированные ../, чтобы клиент не нормализовал путь до отправки.
|
||||
const payloads = [
|
||||
'/uploads/..%2f..%2f..%2f..%2fetc%2fpasswd',
|
||||
'/uploads/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
|
||||
'/uploads/....//....//etc/passwd',
|
||||
]
|
||||
const hits: string[] = []
|
||||
for (const p of payloads) {
|
||||
const r = await axios.get(`${BASE}${p}`, { httpsAgent, validateStatus: () => true })
|
||||
const body = typeof r.data === 'string' ? r.data : JSON.stringify(r.data)
|
||||
if (r.status === 200 && /root:.*:0:0:/.test(body)) hits.push(p)
|
||||
}
|
||||
check(step, { kind: 'api', description: 'Path-traversal не отдаёт системные файлы', ok: hits.length === 0, detail: hits.length ? `УТЕЧКА: ${hits.join(', ')}` : 'все отбиты (404)' })
|
||||
if (hits.length) report.bug({ step: '03', severity: 'critical', title: 'Path traversal на /uploads читает файлы ФС', detail: hits.join(', ') })
|
||||
}
|
||||
|
||||
export async function step04_sql_injection_safe({ ctx, step, report }: StepCtx) {
|
||||
await bootstrapA(ctx)
|
||||
const api = makeClient(ctx.aToken!)
|
||||
const before = q1(`SELECT count(*) FROM products`)
|
||||
const payloads = [`'; DROP TABLE products;--`, `' OR '1'='1`, `%'; DELETE FROM products WHERE ''='`]
|
||||
let allOk = true
|
||||
for (const q of payloads) {
|
||||
const r = await api.get(`/api/catalog/products/quick-search?q=${encodeURIComponent(q)}`)
|
||||
if (r.status >= 500) allOk = false
|
||||
}
|
||||
const after = q1(`SELECT count(*) FROM products`)
|
||||
check(step, { kind: 'api', description: 'quick-search с инъекцией не падает (нет 5xx)', ok: allOk })
|
||||
check(step, { kind: 'db', description: 'Таблица products цела (count не изменился)', ok: before === after && Number(after) > 0, detail: `before=${before} after=${after}` })
|
||||
if (before !== after) report.bug({ step: '04', severity: 'critical', title: 'SQL-инъекция изменила данные', detail: `products: ${before} → ${after}` })
|
||||
}
|
||||
|
||||
export async function step05_tenant_404_not_403({ ctx, step, report }: StepCtx) {
|
||||
await bootstrapA(ctx)
|
||||
if (!ctx.aProductId) { step.status = 'skip'; return }
|
||||
const sa = await ensureSa(ctx)
|
||||
const orgB = await makeClient(sa).post('/api/super-admin/organizations', {
|
||||
org: { name: `SecB ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
|
||||
adminLastName: 'SecB', adminFirstName: 'Admin', adminEmail: `secb-${TS}@example.kz`, adminPosition: null,
|
||||
})
|
||||
const bToken = (await login(orgB.data.adminEmail, orgB.data.adminTempPassword)).accessToken
|
||||
const r = await makeClient(bToken).get(`/api/catalog/products/${ctx.aProductId}`)
|
||||
check(step, { kind: 'api', description: 'Товар чужого тенанта → 404 (не 200/403)', ok: r.status === 404, detail: `status=${r.status}` })
|
||||
if (r.status === 200) report.bug({ step: '05', severity: 'critical', title: 'Multi-tenant утечка: чтение товара чужой орг', detail: `status=200` })
|
||||
}
|
||||
|
||||
export async function step06_cors_evil_origin({ step, report }: StepCtx) {
|
||||
const r = await axios.get(`${BASE}/health`, { httpsAgent, headers: { Origin: 'http://evil.com' }, validateStatus: () => true })
|
||||
const acao = r.headers['access-control-allow-origin']
|
||||
check(step, { kind: 'api', description: 'ACAO не равен http://evil.com', ok: acao !== 'http://evil.com', detail: `ACAO=${acao ?? '(нет)'}` })
|
||||
if (acao === 'http://evil.com') report.bug({ step: '06', severity: 'high', title: 'CORS отражает произвольный Origin', detail: 'evil.com разрешён' })
|
||||
}
|
||||
24
tests/e2e/scenarios/security-edge.yml
Normal file
24
tests/e2e/scenarios/security-edge.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: security-edge
|
||||
description: |
|
||||
Безопасность (ТЗ 2.17): защищённые эндпоинты требуют токен (401 без него),
|
||||
path-traversal на /uploads отбивается, SQL-инъекция в поиске безопасна (EF
|
||||
параметризует), доступ к сущности чужого тенанта → 404 (не 403, не утечка),
|
||||
CORS не отражает чужой Origin.
|
||||
|
||||
preconditions:
|
||||
reset_db: true
|
||||
smoke_login_super_admin: true
|
||||
|
||||
steps:
|
||||
- id: step01_protected_require_auth
|
||||
title: "Защищённые эндпоинты без токена → 401"
|
||||
- id: step02_anonymous_open
|
||||
title: "Анонимные эндпоинты (/health) доступны без токена"
|
||||
- id: step03_path_traversal_uploads
|
||||
title: "Path-traversal /uploads/..%2f..%2fetc/passwd → не 200 (404)"
|
||||
- id: step04_sql_injection_safe
|
||||
title: "SQL-инъекция в quick-search безопасна, таблица цела"
|
||||
- id: step05_tenant_404_not_403
|
||||
title: "GET товара чужого тенанта → 404 (не 403, не 200)"
|
||||
- id: step06_cors_evil_origin
|
||||
title: "CORS не отражает Origin http://evil.com"
|
||||
Loading…
Reference in a new issue