Механические проверки безопасности для проектов ETERN8 на Next.js.
Пакет закрывает три класса дефектов, каждый из которых доезжал до прода на боевом магазине и ни один из которых не виден ни в обзоре кода, ни в обычных тестах:
- Мутация админки без проверки права. Server action — публично вызываемая точка. Гейт в layout раздела закрывает экран, но не операцию: к экшену обращаются POST-ом мимо интерфейса.
"use server"на файле открывает ВСЕ экспорты. Директива стоит на файле, а не на функции, поэтому «служебная» функция рядом с закрытыми обёртками тоже вызываема снаружи.- Предел частоты, который есть и не работает. Если ключ считается по
ЛЕВОМУ узлу
X-Forwarded-For, клиент подставляет заголовок и получает новое ведро счётчика. Предел выглядит рабочим и не ограничивает ничего.
Все три дефекта возникли при том, что правило было известно и записано. Первый повторялся четыре раза подряд, потому что список проверяемых файлов задавался руками и новые разделы в него не попадали. Третий появился потому, что верное правило скопировали в три места и в одной копии оно разошлось.
Отсюда устройство пакета: файлы ищутся по директиве, а не по имени, проверяется каждый экспорт, и ни один файл не может остаться без решения — либо закрыт правом, либо внесён в список с письменной причиной.
Пакет ставится прямо из git по тегу — реестр не нужен:
pnpm add -D "github:yashafake/etern8-secure#v0.1.4"Обязательный второй шаг для pnpm 10+. Любую git-зависимость pnpm считает
требующей сборки и отказывается ставить, пока её не разрешили явно. Добавьте в
pnpm-workspace.yaml проекта:
allowBuilds:
"@etern8/secure": trueБез этой записи установка падает с ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED.
Отказ громкий и на этапе установки, так что незамеченным он не пройдёт.
Собранный dist лежит прямо в репозитории, поэтому у потребителя ничего не
компилируется — разрешение нужно только чтобы снять запрет pnpm.
Анализаторы возвращают находки и ничего не бросают — тестовый движок им не нужен. Проект пишет один тест:
import { describe, expect, it } from "vitest";
import {
analyzeActionSurface,
analyzeHeaderTrust,
analyzeRateLimits,
collectSources,
formatFindings,
} from "@etern8/secure";
const files = collectSources({
root: process.cwd(),
roots: ["lib", "app", "components"],
});
describe("стандарт безопасности", () => {
it("у каждой админской мутации есть право", () => {
const findings = analyzeActionSurface(files, {
isAdminFile: ({ path, source }) =>
path.includes("(admin)") ||
/(^|\/)lib\/admin\//.test(path) ||
path.endsWith("admin-actions.ts") ||
/@\/lib\/admin\/(guard|rbac)/.test(source),
checksRight: (body) => /requireAction\(|requireSection\(/.test(body),
customerFacing: {
"lib/wishlist/actions.ts": "своё избранное под сессией",
},
});
expect(findings, formatFindings(findings)).toEqual([]);
});
it("адрес клиента берётся по одному правилу", () => {
const findings = analyzeHeaderTrust(files, { ipRuleHome: "lib/rate-limit.ts" });
expect(findings, formatFindings(findings)).toEqual([]);
});
it("точки без входа ограничены по частоте", () => {
const findings = analyzeRateLimits(files, {
publicActionFiles: ["lib/auth/actions.ts", "lib/contact/actions.ts"],
});
expect(findings, formatFindings(findings)).toEqual([]);
});
});import { clientIpFromHeaders } from "@etern8/secure/runtime";
// в server action
const ip = clientIpFromHeaders(await headers());
// в route handler
const ip = clientIpFromRequest(request);Правило: берём X-Real-IP, иначе правый узел X-Forwarded-For — тот, что
дописал наш прокси. Держать это одной функцией важнее, чем кажется: именно
размножение трёх строк дало обход предела на входе в админку.
Условие применимости. Правило верно, пока запрос ВСЕГДА проходит через ваш прокси и тот дописывает узел. Если приложение доступно напрямую, минуя прокси, правый узел так же подделен, как левый. Закрыть прямой доступ — часть стандарта, а не деталь развёртывания.
| Проверка | Обязательное | Полезное |
|---|---|---|
analyzeActionSurface |
isAdminFile, checksRight |
customerFacing, readOnlyExports, guardImportPattern |
analyzeHeaderTrust |
ipRuleHome |
— |
analyzeRateLimits |
publicActionFiles |
sessionBounded, hasRateLimit |
Списки исключений намеренно требуют причину строкой, а не голый путь: без неё список за пару месяцев превращается в способ обойти правило.
По умолчанию за обёртку-гейт считаются ввезённые имена вида guard*. Не
записывайте сюда функции типа hasAction, которые возвращают булево, чтобы
спрятать кнопку: они ничего не запрещают, и их зачёт превратит проверку в
пропускающую ровно тот дефект, ради которого она стоит.
Разбор текстовый, без AST — сознательно: проверка обязана гоняться в любом проекте без сборки, плагинов и совпадения версий парсера, иначе её не подключат. Цена — слепые зоны, и они закрыты запретами, а не умолчанием:
- экшен, объявленный стрелкой в
export const, разбор не видит → такая форма запрещена отдельной находкойaction.arrow-export; - гейт засчитывается по вызову имени, поэтому обёртку, которая право не
проверяет, но названа
guardX, пакет засчитает. Имена обёрток — часть ревью, а не проверки.
Пакет проверяет наличие права и предела, но не их правильность: что роль подобрана верно и что предел не задран — вопрос обзора кода.
Анализаторы прогнаны по дереву боевого магазина в состоянии до починок и
независимо переоткрыли все дефекты, найденные тогда вручную за два подхода: три
незакрытых экспорта в одном файле с директивой, взятие левого узла
X-Forwarded-For в проверке входа, скопированное правило извлечения адреса и
четыре публичные точки без ограничения частоты — десять находок.
На том же дереве после починок — ноль находок.
Это и есть смысл пакета: два подхода ручного аудита он воспроизводит за один прогон и делает это на каждом коммите.
pnpm install
pnpm test
pnpm typecheck
pnpm builddist лежит в репозитории, поэтому его легко забыть пересобрать — тогда
потребители получат старый код под новым тегом. Порядок такой:
pnpm test && pnpm typecheck
pnpm build # обязательно ДО коммита
# поднять "version" в package.json
git add -A && git commit
git tag -a vX.Y.Z -m "…" && git push origin main --tags