preview host 허용 회귀 가드 추가 (check:preview-hosts)
배포 도메인 블랭크(vite preview allowedHosts 누락) 재발을 막는 행동 검증 스크립트를 추가. preview를 띄워 공개 Host(vnet/api-vnet/chanpaca) 3종이 200으로 허용되고 임의 Host는 403으로 차단(DNS 리바인딩 보호 유지)되는지 http.request로 직접 Host 헤더를 보내 확인한다. package.json에 check:preview-hosts로 등록. 검증: npm run check:preview-hosts → 공개 3 allowed + unlisted blocked PASS, exit 0. 대시보드 104차 갱신.
This commit is contained in:
parent
7b2ceafd68
commit
5c4ac04e06
3 changed files with 99 additions and 1 deletions
|
|
@ -9,6 +9,7 @@
|
|||
"build": "tsc -b && vite build",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"check:api-types": "node scripts/generate-api-types.mjs --check",
|
||||
"check:preview-hosts": "node scripts/check-preview-hosts.mjs",
|
||||
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
|
|
|
|||
97
apps/web/scripts/check-preview-hosts.mjs
Normal file
97
apps/web/scripts/check-preview-hosts.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-preview-hosts — vite preview 서버의 host 허용 회귀 가드.
|
||||
*
|
||||
* 배포는 `npm run preview`를 cloudflared 터널로 공개 도메인에 연결한다.
|
||||
* vite 6 preview는 Host 헤더를 `preview.allowedHosts`로 검증하므로, 공개 호스트가
|
||||
* 목록에 없으면 "Blocked request. This host is not allowed." 403만 반환되어
|
||||
* 배포 도메인 전체가 블랭크가 된다(2026-06-30 회귀). 이 스크립트는 dist를 preview로
|
||||
* 띄워, 공개 Host는 통과(200, app HTML)하고 임의 Host는 차단되는지 확인한다.
|
||||
*
|
||||
* Node의 fetch는 Host 헤더 오버라이드를 무시하므로 http.request로 직접 Host를 보낸다.
|
||||
*
|
||||
* 사용: node scripts/check-preview-hosts.mjs (dist 빌드가 선행되어야 한다)
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const PORT = Number(process.env.PREVIEW_CHECK_PORT ?? 5191);
|
||||
const PUBLIC_HOSTS = ["vnet.18ka.net", "api-vnet.18ka.net", "vignette.chanpaca.net"];
|
||||
const DISALLOWED_HOST = "evil.example.com";
|
||||
const BLOCK_MARKER = "Blocked request";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const viteBin = isWin ? "node_modules\\.bin\\vite.cmd" : "node_modules/.bin/vite";
|
||||
|
||||
function startPreview() {
|
||||
return spawn(viteBin, ["preview", "--host", "127.0.0.1", "--port", String(PORT)], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: isWin,
|
||||
});
|
||||
}
|
||||
|
||||
/** http.request로 명시 Host 헤더를 보낸다(fetch는 Host 오버라이드를 무시함). */
|
||||
function getWithHost(host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: "127.0.0.1", port: PORT, path: "/", method: "GET", headers: { Host: host } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, text: body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForUp(timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const { status } = await getWithHost("127.0.0.1");
|
||||
if (status === 200) return true;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function killTree(child) {
|
||||
if (!child || child.killed) return;
|
||||
if (isWin && child.pid) {
|
||||
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: true });
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
let preview;
|
||||
let failed = false;
|
||||
try {
|
||||
preview = startPreview();
|
||||
const up = await waitForUp();
|
||||
if (!up) {
|
||||
console.error("FAIL preview server did not come up; build dist first (npm run build)");
|
||||
failed = true;
|
||||
} else {
|
||||
for (const host of PUBLIC_HOSTS) {
|
||||
const { status, text } = await getWithHost(host);
|
||||
const ok = status === 200 && !text.includes(BLOCK_MARKER) && text.includes("<!DOCTYPE html");
|
||||
console.log(`${ok ? "PASS" : "FAIL"} public host allowed: ${host} (status=${status})`);
|
||||
if (!ok) failed = true;
|
||||
}
|
||||
const neg = await getWithHost(DISALLOWED_HOST);
|
||||
const blocked = neg.text.includes(BLOCK_MARKER);
|
||||
console.log(`${blocked ? "PASS" : "FAIL"} unlisted host blocked: ${DISALLOWED_HOST} (status=${neg.status})`);
|
||||
if (!blocked) failed = true;
|
||||
}
|
||||
} finally {
|
||||
killTree(preview);
|
||||
}
|
||||
console.log(failed ? "FAIL preview host check" : "PASS preview host check");
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -536,7 +536,7 @@
|
|||
<p class="dg-note">101차 적용(2026-06-30): 관리자(<code>role=admin</code>)가 학습자·교수자·관리자 워크스페이스를 모두 직접 오갈 수 있도록 권한 경계를 정리했다. 백엔드는 <code>Principal.can_access_role()</code>이 <code>role=admin</code>이면 모든 역할 접근을 허용하고, <code>require_role()</code>은 super_admin뿐 아니라 admin도 허용 역할로 승격한다. 학습자 전용 경로(<code>/consent</code> accept/withdraw, <code>/voice/ws</code>)의 super_admin-only 승격 분기도 <code>can_access_role(Role.LEARNER)</code> 기준으로 바꿔 admin이 동일하게 통과한다. 프론트는 <code>auth.tsx</code>의 <code>canAccessRole()</code>에 admin 전체 접근을 반영하고, 역할 전환 메뉴는 <code>accessibleRolesFor()</code> 단일 헬퍼가 소유하며, <code>Sidebar</code> admin 내비에 <code>운영 홈</code>·<code>교수 콘솔</code>·<code>학습자 홈</code>·<code>학습</code>·<code>기록</code> 진입을 추가했다. 외부 API 계약·DB schema·승인 게이트(<code>account_status</code>)·super_admin 권한 부여/회수 동작은 바꾸지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run check:api-types</code>, <code>py -3.11 -X utf8 -B -m pytest app/ -q</code> 290 passed(RBAC/runtime 30 포함), admin 워크스페이스 내비 E2E 1 passed, layout 시각게이트 9/9, session-layout 4 passed.</p>
|
||||
<p class="dg-note">102차 적용(2026-06-30): 소유자(윤찬) 결정 4건이 확정되어 owner 보드 결정 카드 7→3으로 줄었다. (1) <b>s2s 2차 PoC 유지</b> — 캐스케이드 1차를 운영 기준으로 두면서 <code>voice-s2s-poc.md</code> 비교 PoC를 계속 연다(최종 채택은 비교 리포트 이후). (2) <b>fast-loop 외부 폴백 허용</b> — 로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용, 전송 시 데이터주권·보존·동의 가드 적용. (3) <b>재귀학습 fine-tuning 포함</b> — few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함하되, 데이터셋은 동의·추가 축어록 수급 게이트가 닫힌 뒤 구성. (4) <b>서비스 음성 = 대체 TTS provider</b> — 상업 라이선스가 명확한 대체 provider 채택, Higgs synthetic·dev sample provider는 개발 플래그로만 유지, 실존 인물/성우 ref 금지 유지. 네 결정 모두 방향 확정이며 구현/실측은 후속 build 항목으로 남는다. 남은 owner 결정은 평가설계(H1 κ/ICC), 운영 보존·에스컬레이션, 추가 축어록 수급 3건이다. SSOT drift 게이트(<code>check-dev-dashboard-ssot.py</code>)의 기대 카운트도 결정 3·decision panel 3행으로 동기화했다.</p>
|
||||
<p class="dg-note">103차 적용(2026-06-30): 남은 소유자 결정 3건이 확정되어 owner 결정 7건이 전건 확정(결정 필요 3→0)됐다. (1) <b>평가설계 H1</b> — 평가자 신뢰도 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 독립표본 t-검정(α=0.05), 20명 실험/통제군 무작위 배정, 단회기 50분·3척도 pre-post 유지. (2) <b>운영 보존·에스컬레이션</b> — 헬스 raw 90일·rollup 365일, escalation 운영자 수동 승인, SLA 미표기(관측 샘플 이력 유지), 담당 그룹 자동 배정 미도입. (3) <b>추가 축어록</b> — 20건을 1개월 내 수급. 이 결정들은 설계·정책 확정이며, KPI threshold 반영·retention 기본값 적용·실제 수급/파일럿 evidence·통계해석은 후속 build/GATE로 남는다. 즉 owner 보드의 남은 항목은 모두 외부 증거·기관 의존(공개 OAuth 실 <code>/turn</code>, vnet DNS, 한신대 거버넌스, 임상팀 콘텐츠, 20명 파일럿·법무·물리 실측)이며, 운영 원칙상 E2E/외부 증거 없이는 DONE으로 체크하지 않는다. SSOT drift 게이트의 기대 카운트도 결정 0·decision panel 0행으로 동기화했다.</p>
|
||||
<p class="dg-note">104차 적용(2026-06-30): 공개 배포 도메인(<code>vnet.18ka.net</code>)에서 슈퍼관리자가 관리자 페이지 전체를 블랭크로 보던 문제를 고쳤다. 원인은 권한이 아니라 <code>vite preview</code> 서버의 host 검증이었다. 배포는 <code>npm run preview</code>를 cloudflared 터널로 공개 도메인에 연결하는데, <code>vite.config.ts</code>에 <code>preview.allowedHosts</code>가 없어 공개 Host 요청이 <code>Blocked request. This host is not allowed.</code> 403 텍스트만 받아 SPA가 통째로 로드되지 못했다(에러 바운더리 부재로 전체 흰 화면). <code>server.allowedHosts</code>와 동일 목록을 <code>preview</code>에도 적용하고 기본 허용에 <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code>/<code>vignette.chanpaca.net</code>를 추가했다. 검증: 재현(<code>curl -H "Host: vnet.18ka.net" 127.0.0.1:5174</code> → 수정 전 Blocked, 수정 후 <code>200</code>+정상 index), 로컬 dev·preview에서 일반/슈퍼 관리자 전 admin·teach·learn 페이지 정상 렌더(pageerror 0), <code>npm run typecheck</code>. 공개 OAuth 실 <code>/turn</code> 증명은 여전히 별도 BLOCK이다.</p>
|
||||
<p class="dg-note">104차 적용(2026-06-30): 공개 배포 도메인(<code>vnet.18ka.net</code>)에서 슈퍼관리자가 관리자 페이지 전체를 블랭크로 보던 문제를 고쳤다. 원인은 권한이 아니라 <code>vite preview</code> 서버의 host 검증이었다. 배포는 <code>npm run preview</code>를 cloudflared 터널로 공개 도메인에 연결하는데, <code>vite.config.ts</code>에 <code>preview.allowedHosts</code>가 없어 공개 Host 요청이 <code>Blocked request. This host is not allowed.</code> 403 텍스트만 받아 SPA가 통째로 로드되지 못했다(에러 바운더리 부재로 전체 흰 화면). <code>server.allowedHosts</code>와 동일 목록을 <code>preview</code>에도 적용하고 기본 허용에 <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code>/<code>vignette.chanpaca.net</code>를 추가했다. 검증: 재현(<code>curl -H "Host: vnet.18ka.net" 127.0.0.1:5174</code> → 수정 전 Blocked, 수정 후 <code>200</code>+정상 index), 로컬 dev·preview에서 일반/슈퍼 관리자 전 admin·teach·learn 페이지 정상 렌더(pageerror 0), <code>npm run typecheck</code>. 회귀 방지로 <code>npm run check:preview-hosts</code>(<code>scripts/check-preview-hosts.mjs</code>)를 추가해 preview를 띄워 공개 Host 3종 200 허용·임의 Host 403 차단을 행동 검증한다(공개 3 allowed·unlisted blocked 통과). 공개 OAuth 실 <code>/turn</code> 증명은 여전히 별도 BLOCK이다.</p>
|
||||
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
|
||||
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
|
||||
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue