From d91e4dba6eccebf0b14e4459ac7a6f4032110294 Mon Sep 17 00:00:00 2001
From: Claude
missing
", + }); + await expect( + readPublicDocument("https://example.com/", new AbortController().signal), + ).rejects.toMatchObject({ status: 422 }); +}); + +it("still refuses redirects to insecure or internal targets on the security path", async () => { + for (const location of [ + "http://example.com/", + "https://example.com:8443/", + "https://10.0.0.1/", + ]) { + replies.set("https://example.com/", { status: 301, headers: { location } }); + await expect( + readPublicDocument("https://example.com/", new AbortController().signal, { + anyResponse: true, + }), + ).rejects.toMatchObject({ status: 422 }); + } +}); diff --git a/src/lib/server/security-check.test.ts b/src/lib/server/security-check.test.ts index a934ceb..0cfe2d5 100644 --- a/src/lib/server/security-check.test.ts +++ b/src/lib/server/security-check.test.ts @@ -54,3 +54,29 @@ it("observes headers without exporting cookie secrets or claiming policy effecti expect(JSON.stringify(report)).not.toContain("secret"); expect(report.findings.find((f) => f.id === "mime")?.status).toBe("observed"); }); +it("accepts the RFC 6797 quoted max-age form but not unbalanced quotes", () => { + const hsts = (value: string) => + inspectSecurity({ ...document, headers: { "strict-transport-security": value } }).findings.find( + (f) => f.id === "hsts", + )?.status; + expect(hsts('max-age="31536000"; includeSubDomains')).toBe("observed"); + expect(hsts('includeSubDomains; MAX-AGE = "300"')).toBe("observed"); + expect(hsts('max-age="0"')).toBe("review"); + expect(hsts('max-age="31536000')).toBe("review"); +}); +it("reports the final status and redirect route without site-chosen query strings", () => { + const report = inspectSecurity({ + ...document, + url: "https://www.example.com/?lang=ko&session=abc", + status: 403, + redirects: ["https://example.com/"], + }); + expect(report.url).toBe("https://www.example.com/"); + expect(report.status).toBe(403); + expect(report.redirects).toEqual(["https://example.com/"]); + const text = securityReportText(report); + expect(text).toContain("이동 경로: https://example.com/ → https://www.example.com/"); + expect(text).toContain("최종 응답 코드 403"); + expect(text).not.toContain("session=abc"); + expect(inspectSecurity(document)).not.toHaveProperty("redirects"); +}); diff --git a/src/lib/server/security-check.ts b/src/lib/server/security-check.ts index 906759e..a25cd8f 100644 --- a/src/lib/server/security-check.ts +++ b/src/lib/server/security-check.ts @@ -1,5 +1,12 @@ import type { PublicDocument } from "./project-page"; import type { SecurityFinding, SecurityReport } from "../security-check"; +/** Redirect targets may carry session parameters chosen by the site; report paths only. */ +function withoutQuery(value: string) { + const url = new URL(value); + url.search = ""; + url.hash = ""; + return url.href; +} export function inspectSecurity(document: PublicDocument): SecurityReport { const header = (name: string) => { const value = document.headers[name]; @@ -12,7 +19,9 @@ export function inspectSecurity(document: PublicDocument): SecurityReport { .replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ""); const metaCsp = /]*http-equiv\s*=\s*["']?content-security-policy["'\s>]/i.test(markup); const hsts = header("strict-transport-security"); - const maxAge = hsts.match(/(?:^|;)\s*max-age\s*=\s*(\d+)\s*(?:;|$)/i); + // RFC 6797 allows a quoted-string value: max-age="31536000". + const maxAge = hsts.match(/(?:^|;)\s*max-age\s*=\s*(?:(\d+)|"(\d+)")\s*(?:;|$)/i); + const maxAgeSeconds = maxAge ? Number(maxAge[1] ?? maxAge[2]) : 0; const frame = csp .split(",") .some((policy) => policy.split(";").some((d) => /^\s*frame-ancestors\s+\S/i.test(d))); @@ -36,9 +45,9 @@ export function inspectSecurity(document: PublicDocument): SecurityReport { { id: "hsts", title: "HTTPS 유지 정책 (HSTS)", - status: maxAge && Number(maxAge[1]) > 0 ? "observed" : "review", + status: maxAgeSeconds > 0 ? "observed" : "review", evidence: - maxAge && Number(maxAge[1]) > 0 + maxAgeSeconds > 0 ? "양수 max-age를 가진 HSTS 헤더가 있습니다. 최초 접속과 하위 도메인의 보장은 별도 확인이 필요합니다." : "유효한 양수 max-age를 가진 HSTS 헤더를 관찰하지 못했습니다. 현재 요청 자체는 HTTPS입니다.", action: @@ -87,5 +96,11 @@ export function inspectSecurity(document: PublicDocument): SecurityReport { "아래 모의해킹 준비 과제를 본인의 테스트 환경에서 수행하세요. 서버가 소유자와 현재 권한을 확인하는지 비교해야 합니다.", }, ]; - return { url: document.url, checkedAt: document.fetchedAt, findings }; + return { + url: withoutQuery(document.url), + checkedAt: document.fetchedAt, + findings, + ...(document.status ? { status: document.status } : {}), + ...(document.redirects?.length ? { redirects: document.redirects.map(withoutQuery) } : {}), + }; } diff --git a/src/lib/server/security-route.test.ts b/src/lib/server/security-route.test.ts index 9958f44..fbda7f6 100644 --- a/src/lib/server/security-route.test.ts +++ b/src/lib/server/security-route.test.ts @@ -1,16 +1,20 @@ import { beforeEach, expect, it, vi } from "vitest"; -const { session, consume, read } = vi.hoisted(() => ({ +const { session, consume, refund, read } = vi.hoisted(() => ({ session: vi.fn(), consume: vi.fn(), + refund: vi.fn(), read: vi.fn(), })); vi.mock("./session", () => ({ session })); -vi.mock("./database", () => ({ getStore: async () => ({ consumeLimits: consume }) })); +vi.mock("./database", () => ({ + getStore: async () => ({ consumeLimits: consume, queries: { refundLimits: refund } }), +})); vi.mock("./project-page", async (importOriginal) => ({ ...(await importOriginal+ {pendingTask.label} 분석이 진행 중입니다. 끝나면 이 화면에서 결과를 엽니다. +
+- 새 분석 1회가 사용됩니다. AI 호출 후 응답을 받지 못한 경우에도 횟수가 차감될 수 - 있습니다. 한도는 기존 코딩 문제 생성과 별개입니다. -
- {busy &&필요한 코드를 복사해 현재 초안에 합칠 수 있습니다. 원본은 계속 보관됩니다.
- {recoverable.map((draft, index) => ( -+ 필요한 코드를 복사해 현재 초안에 합칠 수 있습니다. 원본은 삭제하기 전까지 + 보관됩니다. +
+ {archived.map((draft, index) => ( +