food-market/tests/e2e/scenarios/security-edge.steps.ts
nns 2fd3b6d75c 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>
2026-05-26 11:58:54 +05:00

130 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 разрешён' })
}