chore(mera-public): isolation guard + favicon + Caddy handle (#2631)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
Аудит заявленной утечки /api/v1/me: в текущем исходнике не воспроизводится — next build + standalone-сервер + разбор app-build-manifest показали, что chunk-split #2545 (GuardedRoute за next/dynamic) чист, ни один чанк публичного роута не содержит /api/v1/me. Live-проверка meraocenka.ru подтверждает: во всех 12 чанках лэндинга строки нет. Репорт issue почти наверняка ловил stale-образ (rapid-merge trap, .claude/rules/tradein.md). Чтобы инвариант не отрегрессил молча — source-level guard (scripts/check-mera-public-isolation.mjs, BFS по статическим import'ам от корневого layout + mera-public; dynamic import не следуем — семантика webpack-чанков; проверен инъекцией нарушения) + шаг в CI frontend-checks. favicon.ico (мульти-размер, брендовые токены) + Caddy handle /favicon.ico в периметре meraocenka.ru (allowlist-404 резал его до Next). Refs #2631
This commit is contained in:
parent
7d13e93792
commit
deff85d8b6
5 changed files with 176 additions and 1 deletions
|
|
@ -140,3 +140,9 @@ jobs:
|
||||||
- name: Lint (next lint)
|
- name: Lint (next lint)
|
||||||
# Blocking: любая ESLint-ошибка → job RED.
|
# Blocking: любая ESLint-ошибка → job RED.
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Mera-public isolation guard (#2631)
|
||||||
|
# Blocking: статический import-graph публичного лэндинга не должен
|
||||||
|
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
|
||||||
|
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
|
||||||
|
run: npm run check:mera-public-isolation
|
||||||
|
|
|
||||||
10
Caddyfile
10
Caddyfile
|
|
@ -282,6 +282,16 @@ meraocenka.ru {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #2631: favicon — единственный корневой статик, который браузер запрашивает
|
||||||
|
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
|
||||||
|
# по корневому пути через basePath /trade-in.
|
||||||
|
handle /favicon.ico {
|
||||||
|
rewrite * /trade-in/favicon.ico
|
||||||
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||||||
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
||||||
handle {
|
handle {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.50.0",
|
"@tanstack/react-query": "^5.50.0",
|
||||||
|
|
|
||||||
158
tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs
Normal file
158
tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Guard for issue #2631 / #2545: публичный B2C-лэндинг `/mera-public` должен
|
||||||
|
* оставаться "пустым по зависимостям" — ни один файл, который Next.js грузит
|
||||||
|
* СТАТИЧЕСКИ (initial JS для этого route-чанка), не должен транзитивно
|
||||||
|
* дотягиваться до закрытого контура (useMe / lib/api / lib/sessionId /
|
||||||
|
* isPathAllowed / RBAC). Никакого test runner (jest/vitest) в проекте нет
|
||||||
|
* (см. package.json) — это source-level guard вместо test-suite.
|
||||||
|
*
|
||||||
|
* НЕ подключён в `.forgejo/workflows/ci-tradein.yml` этим PR (вне scope —
|
||||||
|
* только `tradein-mvp/frontend/**`). Чтобы он реально гейтил PR, нужен один
|
||||||
|
* доп. `run: npm run check:mera-public-isolation` шаг в job `frontend-checks`
|
||||||
|
* рядом с `npm run lint` — отдельным devops/frontend PR.
|
||||||
|
*
|
||||||
|
* Как это соотносится с реальным webpack-чанком: `RouteGuard.tsx` намеренно
|
||||||
|
* подключает `GuardedRoute` через `next/dynamic(() => import(...))` — то есть
|
||||||
|
* точку разрыва графа модулей (см. шапки RouteGuard.tsx / GuardedRoute.tsx).
|
||||||
|
* Этот скрипт следует ТОЛЬКО статическим edges (`import ... from "..."` /
|
||||||
|
* `export ... from "..."` / side-effect `import "..."`) и НИКОГДА не идёт
|
||||||
|
* внутрь динамического `import(...)` — ровно то же правило, по которому
|
||||||
|
* webpack решает, что попадёт в один чанк, а что уедет в отдельный.
|
||||||
|
*
|
||||||
|
* Запуск: `node scripts/check-mera-public-isolation.mjs` (npm run
|
||||||
|
* check:mera-public-isolation). Exit 1 + печать цепочки импортов при находке.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SRC = path.resolve(__dirname, "..", "src");
|
||||||
|
|
||||||
|
const RESOLVABLE_EXTS = [".ts", ".tsx", ".js", ".jsx"];
|
||||||
|
const STATIC_IMPORT_RE = /(?:^|\s)(?:import|export)\s[^;]*?\sfrom\s*["']([^"']+)["']|(?:^|\s)import\s*["']([^"']+)["']/g;
|
||||||
|
|
||||||
|
function collectFilesRecursively(dir) {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = path.join(dir, entry);
|
||||||
|
const st = statSync(full);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
out.push(...collectFilesRecursively(full));
|
||||||
|
} else if (RESOLVABLE_EXTS.includes(path.extname(full))) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всё, что Next.js грузит для /mera-public НЕЗАВИСИМО от dynamic()-разрывов:
|
||||||
|
// корневой layout (Providers + RouteGuard) + всё поддерево лэндинга.
|
||||||
|
const ENTRY_FILES = [
|
||||||
|
path.join(SRC, "app/layout.tsx"),
|
||||||
|
path.join(SRC, "app/providers.tsx"),
|
||||||
|
path.join(SRC, "components/auth/RouteGuard.tsx"),
|
||||||
|
...collectFilesRecursively(path.join(SRC, "app/mera-public")),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Закрытый контур — см. GuardedRoute.tsx шапку. GuardedRoute.tsx сам тоже
|
||||||
|
// forbidden: он обязан доезжать ТОЛЬКО через next/dynamic, никогда статически.
|
||||||
|
const FORBIDDEN = [
|
||||||
|
"lib/useMe.ts",
|
||||||
|
"lib/api.ts",
|
||||||
|
"lib/sessionId.ts",
|
||||||
|
"lib/isPathAllowed.ts",
|
||||||
|
"components/auth/GuardedRoute.tsx",
|
||||||
|
].map((p) => path.join(SRC, p));
|
||||||
|
|
||||||
|
function resolveSpecifier(spec, fromFile) {
|
||||||
|
let base;
|
||||||
|
if (spec.startsWith("@/")) {
|
||||||
|
base = path.join(SRC, spec.slice(2));
|
||||||
|
} else if (spec.startsWith(".")) {
|
||||||
|
base = path.resolve(path.dirname(fromFile), spec);
|
||||||
|
} else {
|
||||||
|
return null; // bare specifier — внешний пакет / next/*, не наш граф
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(base) && statSync(base).isFile()) return base;
|
||||||
|
for (const ext of RESOLVABLE_EXTS) {
|
||||||
|
if (existsSync(base + ext)) return base + ext;
|
||||||
|
}
|
||||||
|
for (const ext of RESOLVABLE_EXTS) {
|
||||||
|
const indexed = path.join(base, "index" + ext);
|
||||||
|
if (existsSync(indexed)) return indexed;
|
||||||
|
}
|
||||||
|
return null; // .css / .module.css / картинки / не найдено — лист, не ошибка
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticImportsOf(file) {
|
||||||
|
const src = readFileSync(file, "utf8");
|
||||||
|
const specs = [];
|
||||||
|
let m;
|
||||||
|
STATIC_IMPORT_RE.lastIndex = 0;
|
||||||
|
while ((m = STATIC_IMPORT_RE.exec(src)) !== null) {
|
||||||
|
specs.push(m[1] ?? m[2]);
|
||||||
|
}
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS от всех entry-файлов по СТАТИЧЕСКИМ рёбрам, с parent-map для отчёта.
|
||||||
|
const parent = new Map();
|
||||||
|
const visited = new Set();
|
||||||
|
const queue = [];
|
||||||
|
for (const entry of ENTRY_FILES) {
|
||||||
|
if (!visited.has(entry)) {
|
||||||
|
visited.add(entry);
|
||||||
|
queue.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let violation = null;
|
||||||
|
while (queue.length > 0 && !violation) {
|
||||||
|
const file = queue.shift();
|
||||||
|
for (const forbidden of FORBIDDEN) {
|
||||||
|
if (file === forbidden) {
|
||||||
|
violation = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (violation) break;
|
||||||
|
|
||||||
|
for (const spec of staticImportsOf(file)) {
|
||||||
|
const resolved = resolveSpecifier(spec, file);
|
||||||
|
if (!resolved || visited.has(resolved)) continue;
|
||||||
|
visited.add(resolved);
|
||||||
|
parent.set(resolved, file);
|
||||||
|
queue.push(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainTo(file) {
|
||||||
|
const chain = [file];
|
||||||
|
let cur = file;
|
||||||
|
while (parent.has(cur)) {
|
||||||
|
cur = parent.get(cur);
|
||||||
|
chain.unshift(cur);
|
||||||
|
}
|
||||||
|
return chain.map((f) => path.relative(SRC, f)).join("\n -> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violation) {
|
||||||
|
console.error(
|
||||||
|
"FAIL: /mera-public публичный бандл статически дотягивается до закрытого контура.\n" +
|
||||||
|
"Цепочка импортов:\n " +
|
||||||
|
chainTo(violation) +
|
||||||
|
"\n\nЕсли модуль реально нужен закрытому RBAC-коду — подключай его через " +
|
||||||
|
"next/dynamic(() => import(...)), как GuardedRoute в RouteGuard.tsx, а не " +
|
||||||
|
"статическим import.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`OK: mera-public isolation — ${ENTRY_FILES.length} файлов проверено, закрытый контур не найден.`,
|
||||||
|
);
|
||||||
BIN
tradein-mvp/frontend/src/app/favicon.ico
Normal file
BIN
tradein-mvp/frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Loading…
Add table
Reference in a new issue