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:
Yun Chan 2026-06-30 12:27:46 +09:00
parent 7b2ceafd68
commit 5c4ac04e06
3 changed files with 99 additions and 1 deletions

View file

@ -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",

View 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);