From deff85d8b6e82ff1ec96d09ac2ac78cea0ac4bff Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 11:09:30 +0500 Subject: [PATCH] chore(mera-public): isolation guard + favicon + Caddy handle (#2631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит заявленной утечки /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 --- .forgejo/workflows/ci-tradein.yml | 6 + Caddyfile | 10 ++ tradein-mvp/frontend/package.json | 3 +- .../scripts/check-mera-public-isolation.mjs | 158 ++++++++++++++++++ tradein-mvp/frontend/src/app/favicon.ico | Bin 0 -> 3352 bytes 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs create mode 100644 tradein-mvp/frontend/src/app/favicon.ico diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 4a0bc79c..94b9d2ff 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -140,3 +140,9 @@ jobs: - name: Lint (next lint) # Blocking: любая ESLint-ошибка → job RED. 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 diff --git a/Caddyfile b/Caddyfile index 59402dbf..1e6a0e25 100644 --- a/Caddyfile +++ b/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, # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. handle { diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index b3579017..f71653ba 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs" }, "dependencies": { "@tanstack/react-query": "^5.50.0", diff --git a/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs new file mode 100644 index 00000000..f0612f25 --- /dev/null +++ b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs @@ -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} файлов проверено, закрытый контур не найден.`, +); diff --git a/tradein-mvp/frontend/src/app/favicon.ico b/tradein-mvp/frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b026b8c2b69076a70aacf3ecdad266de0810432e GIT binary patch literal 3352 zcmaKtc{J2*8^?dfHnxnhWZx#s(^!U)k*qZsW{hpDNp>bn4OtpuFl0$~p^Z>Np0S3q zWKAKF$i62HvZutW=lSRT<2~Nn|yW5h|k)sw16OXp7Z^{)lrC9E3u=G7_<5k|sx8lAXi=WgD6|IjWPE5JkMbv6Td zg1J0XJ#tE7BW}E~`$b#W{Jg^T_Q~HHpW^rc!I)vmK^C2b=pL`KsLf9DNra6u21L8S z@LQ5fIa5><-?k)lfsKs8T=!dJW5lxVRS`R3n_4-*zWh|SR~a`Xkbq|*sDpT zlC4sPueke*;YqJM9vW53Z6iBMgQNM-+$f94SRprohl(iD?hhuV2YDS^Spwvnai5FF zZp(%P(%8`bXUXkI9(@1PP|K^OSpj>IAQovOOb$A{)rn|R_Ea%%agnjJnCH$+zeG;j3JW~3s7pV_ z2rxFlqbtrkNBG5CE#@#MoRUPLqOSA5b0+so z&JVw=vpuoVpRQs;G&(nARl4^xD9DDvsYD+ZU_{dn$pn@&mZfW^T?S|i!x5f=*6)!OiRB$AAteEX4X)BKd};X znjJGxkJ?(FyMIj_iKo|L1{d3255GxGdQf9f$!AqboX+)m+JO(3GtLBjtwZR~5&&5l z-R-~pnmkmWaHQfS>8slH!fEa4IKJ_8MTrbFuRphYqy}qYj(d!o0MTqDe8e}TH|}e~ zuiw~-4?5%hEQx+wzO^`DY4uth_}tE5mg%kV?OK!xLE6nI%IA$q!c5qcBRxH3{*Vul z`gNX_;?)KO<7$pkYDwIX2ko|Um}TTe$zl|k4&6vcl)(j~dcov;?m zb!rR!?YxW$;_X_kbuYi-y7>~cH!id!@`0i!q1H!1IhEH}iiEfxL?{REm7bqvQe}y_ z^p)4V=TXJQ%VNsiNZgrRI`F zKERDf-aY_2{c8N~{&FA#UbHly&O1jyOfO4rcb6>(D|j)=Z73&$tyn?#wH#l&KW~{7 zSl^|EE9)C4bqjiSkz^t11zH*rh>ov?XF?)tc?u?boVWarjtnUV_d9e^(sxrEPnqGi z4II7E7h_AWfmBCr94_*0f4XwJSa<%pisFk#ZS6;*Dq_lNY}zaDdxiZ>7K)tgzSgTe z-@=xuWFIA7dNk10{PiN30W^@n+^6KGElejdL^!V0t28xV_ATUBDL3-w>QZmKx8saH z7<)PrVvW7L()g{{+QF~#^vhC9#l3wWvjM*nB=^mEXk}`!Ryn|NRZMmJGDrVd{t6L0 zg=@A+DNuxuL@}A@=?WX(PrP2IG-@6|^tSf!njCSM9bGU+^QhB27-t<{I(zA%WcR}* zINzH4!iejr%?l)xgUhmQiO)v6kefnpV5vQus1HULLhKgOT<^6gHo2!H zI-M@4G(i91UgB1Y6$0_DPuVF#Ldc9`U$@mJ(Yo#CL)(td{?IWOmu&uD65Eft1vfs4 z97oju^@~68h4{xWY(e|C0Dy(_U%wc3F!mL(7CfA~*EioGdy$wIS2GY>YHofq!IZ)d z%@-$s%i@i;`h1E?E|}|hu=|k%D#s*Bxge7Yi57SZ{Xi{7Q`JqYAs^L@S37ole>6$_ zevj-Q+)nK}|Jy9bG6Z)HXBAMmZ7pMe1}fpOky);Tt5? z*}pffLDg$XN%il;1`ekscxKFV^8zl5l!cR&c;^O>(J8^`gFt$R5p~i37Q6doh8Mz! zAJd-O;5FT7$hUBoU?Xi=NWpL@!(?J_3|~9`h~OG@v{j?U6I`~T*21K2!`2OzEHsCdtw|*=O{JG}-i+aEotygH^&n<(cRV_|=|ioQ@@RqHnXxO|zMK zn%|RtP8P^;R@|@XfSc78H1(7Qd|59Bn#FTEW8uuBpR|M`lx~ABJwM^384zN?mb4-` zQ++7<)5rvK7D{HeEDvP~rDwvf&F!D4K~1k5g}n%^M-Uu~jAey0*;0J>NOQ4wJi5gm zvTtlHf(Dg1XeVUV(%`W9z~Yzn-!~Z01MK< zy3%{=mhsA-&I&xYKAKiUIn8Lc?qBNEVlnU|sK&eBR#<&Ko{c%JYcGcXya{ikgQ)1T zae*L_7kaKrK%Ab3dUUx$0CBO=v~C>CV3)*r#4K;%j*Eu%9 zH;=nM=H^gIvogfI&+H3Y=;%?n7D(2?d*f0}6Uv58%{bJEZ{0)wHQxGHC25;JmwMAw3TH)lDjZYhuAWZo246t5o!yFY$s&+G4 zcTQY4WH+boW;}W3MH@2I{Cc-|BL=1Wp!JF8xOX0Q<^8X~tO~flzV1rj?B9{?R{|qH zP)D+M+T+ZYYO(O_dkOsl1P}Yp+aNVB`o2IqrD1`L8}q5c{lL+6-!EP-DH|E_u$N)* zp(&m-%ZWs8Fviu+kRQ($bJ!{pIX*>u;>$Y(q&)x^y1y<}z%JkWD39o48vyouRQa7y#v&~GG+2b5)tOg46i=KfK+j!L7- zn>9#F-ujpw&e+b30kUP$xZ=R7deSGJqMF&qJEsEO?!+A^Du*fP!J7QHbo23SGi|R; JQvW+d{sUx&3#R}8 literal 0 HcmV?d00001