From c9a6e2c3f3f3940901077549765f7d53049c7044 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 18:59:34 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix(security):=20Node=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EC=97=90=EC=84=9C=20=EC=9C=84=EC=A1=B0=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20cf-connecting-ip=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=86=8C=EC=BC=93=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20(H-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이트리밋·감사로그가 cf-connecting-ip 헤더를 무조건 신뢰했다. adapter-node 등 비-Cloudflare 배포에서는 이 헤더를 클라이언트가 임의 주입할 수 있어, 요청마다 IP 를 회전시켜 IP 기반 rate-limit 을 우회하고 audit log 에 위조 IP 를 주입할 수 있었다. Workers 여부(platform.ctx.waitUntil)를 판별해 Workers 에서만 cf-connecting-ip 를 신뢰하고, 그 외에는 event.getClientAddress() 로 실제 peer 주소를 사용한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/audit/index.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/lib/server/audit/index.ts b/src/lib/server/audit/index.ts index 6a0ad69..8ef766f 100644 --- a/src/lib/server/audit/index.ts +++ b/src/lib/server/audit/index.ts @@ -15,14 +15,29 @@ export interface AuditEventInput { detail?: Record; } -// ctrls H-ADMIN-3: X-Forwarded-For fallback 제거. -// Cloudflare Workers 환경에서는 cf-connecting-ip 가 신뢰 가능한 단일 소스이며, -// 외부 요청이 임의로 설정 가능한 X-Forwarded-For 를 fallback 으로 두면 dev -// 환경 또는 잘못된 reverse proxy 구성 하에서 IP 위조로 audit log 오염 / IP 기반 -// rate-limit 우회가 가능해진다. dev 환경에서는 IP 가 null 로 기록되며, 이는 -// 의도된 동작이다 (운영 = CF 뒤에서 동작). +// ctrls H-ADMIN-3 / H-API-1: 신뢰 가능한 클라이언트 IP 결정. +// Cloudflare Workers 환경에서만 cf-connecting-ip 를 신뢰한다 — 이 헤더는 CF 엣지가 +// 설정하며 클라이언트가 위조할 수 없다. Node/기타(adapter-node) 배포에서는 동일 +// 헤더를 외부 요청이 임의로 주입할 수 있으므로 절대 신뢰하지 않고, 어댑터가 제공하는 +// 실제 소켓 주소(event.getClientAddress())를 사용한다. 이를 신뢰하면 요청마다 IP 를 +// 회전시켜 IP 기반 rate-limit 을 우회하고 audit log 에 위조 IP 를 주입할 수 있다. +// +// Workers/Node 판별은 hooks.server.ts 의 GC 스케줄러 분기와 동일한 시그널 +// (platform.ctx.waitUntil 존재 여부)을 사용한다. export function getRequestMetadata(event: RequestEvent) { - const ip = event.request.headers.get("cf-connecting-ip"); + const isWorkers = typeof event.platform?.ctx?.waitUntil === "function"; + let ip: string | null; + if (isWorkers) { + ip = event.request.headers.get("cf-connecting-ip"); + } else { + // adapter-node: 실제 peer 주소. 신뢰된 프록시가 앞단에 있고 forwarded 헤더를 + // 존중해야 한다면 SvelteKit ADDRESS_HEADER 환경변수로 명시적으로 구성해야 한다. + try { + ip = event.getClientAddress(); + } catch { + ip = null; + } + } return { ip: ip ?? null, From 588e4fef60e53f89648e570982cc0e75ee19955c Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:00:40 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix(security):=20LDAP=20starttls=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20STARTTLS=20=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=98=91=EC=83=81=20(H-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tlsMode='starttls' 일 때 ldap://(평문) 으로 연결하고 tlsOptions 만 설정한 채 STARTTLS extended operation 을 호출하지 않아, 관리자가 "암호화(starttls)"를 선택했음에도 bind 자격증명·인증 사용자 비밀번호가 평문 소켓으로 전송됐다. connectLdapClient() 를 추가해 bind 전에 STARTTLS 를 협상하고, 업그레이드 실패 시 평문 bind 로 진행하지 않고 fail-closed 한다. ldapBind/ldapSearchDn/ldapFetchEntry 모두 이 경로를 거친다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/ldap/client.ts | 49 ++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/lib/server/ldap/client.ts b/src/lib/server/ldap/client.ts index d0b3e8e..272c582 100644 --- a/src/lib/server/ldap/client.ts +++ b/src/lib/server/ldap/client.ts @@ -15,6 +15,46 @@ function createLdapClient(config: LdapProviderConfig): ldap.Client { }); } +// ctrls H-API-2: starttls 모드는 반드시 bind 전에 STARTTLS extended operation 을 +// 실제로 협상해야 한다. 예전에는 buildUrl 이 ldap://(평문) 을 반환하고 tlsOptions 만 +// 설정한 채 STARTTLS 를 호출하지 않아, 관리자가 "암호화(starttls)"를 선택했음에도 +// bind 자격증명·사용자 비밀번호가 평문 소켓으로 전송됐다. 업그레이드가 실패하면 +// 평문 bind 로 진행하지 않고 fail-closed 한다 (다운그레이드 방지). +function connectLdapClient(config: LdapProviderConfig): Promise { + const client = createLdapClient(config); + if (config.tlsMode !== "starttls") return Promise.resolve(client); + + // 일부 타입 정의에 starttls/destroy 가 노출되지 않아 명시 캐스팅. + const tlsClient = client as unknown as { + starttls: (options: Record, controls: unknown[], callback: (err: Error | null) => void) => void; + destroy?: () => void; + }; + + return new Promise((resolve, reject) => { + let settled = false; + const failClosed = (err: Error) => { + if (settled) return; + settled = true; + try { + tlsClient.destroy?.(); + } catch { + /* noop */ + } + reject(err); + }; + client.on("error", failClosed); + tlsClient.starttls({ rejectUnauthorized: true }, [], (err: Error | null) => { + if (settled) return; + if (err) { + failClosed(err); + return; + } + settled = true; // 성공 — 이후 lingering error 핸들러는 outer 함수가 처리한다. + resolve(client); + }); + }); +} + /** DN + 패스워드로 LDAP bind. 실패 시 throw. 빈 패스워드는 anonymous bind 가 되므로 거부. */ export async function ldapBind(config: LdapProviderConfig, dn: string, password: string): Promise { if (!password) { @@ -24,9 +64,8 @@ export async function ldapBind(config: LdapProviderConfig, dn: string, password: if (!dn) { throw new Error("LDAP bind: empty DN is not allowed"); } + const client = await connectLdapClient(config); return new Promise((resolve, reject) => { - const client = createLdapClient(config); - client.on("error", (err: Error) => { reject(err); }); @@ -44,9 +83,8 @@ export async function ldapBind(config: LdapProviderConfig, dn: string, password: * ou 가 여러 개인 서버에서 유저 DN 을 찾을 때 사용. */ export async function ldapSearchDn(config: LdapProviderConfig, bindDn: string, bindPassword: string, filter: string): Promise { + const client = await connectLdapClient(config); return new Promise((resolve, reject) => { - const client = createLdapClient(config); - client.on("error", (err: Error) => { reject(err); }); @@ -87,9 +125,8 @@ export async function ldapSearchDn(config: LdapProviderConfig, bindDn: string, b /** bind 후 단일 엔트리의 속성을 조회한다. */ export async function ldapFetchEntry(config: LdapProviderConfig, bindDn: string, bindPassword: string, entryDn: string, attributes: string[]): Promise | null> { + const client = await connectLdapClient(config); return new Promise((resolve, reject) => { - const client = createLdapClient(config); - client.on("error", (err: Error) => { reject(err); }); From 6c70e7e28bc27fca769c5edc0ed87060e3df83ad Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:04:12 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix(security):=20OIDC=20=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=B0=94=EC=9A=B4=EB=93=9C=20=EC=9B=B9=ED=9B=85=20URI?= =?UTF-8?q?=20=EC=97=90=20SSRF=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20(M-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backchannel/frontchannel logout·role-change URI 는 IdP 가 서버측에서 직접 fetch 하는데도 scheme 검증만 있어, 관리자가 169.254.169.254(IMDS)·RFC1918 내부 호스트를 지정하면 Worker egress 에서 서명된 SET/logout JWT 를 내부 엔드포인트로 흘릴 수 있었다 (블라인드 SSRF). LDAP 게이트와 동일 취지로 loopback/사설망/CGNAT/ULA/메타데이터 호스트를 차단하는 isForbiddenWebhookHost + validateWebhookUrl(https 강제) 을 추가하고, oidc-clients create/update 검증(validateSingleUri)에 적용했다. 또한 등록 이전 저장 행을 방어하기 위해 role-change/backchannel logout fetch 직전 assertPublicWebhookUrl 재검증(fail-closed)을 추가했다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/i18n/en.json | 2 + src/lib/i18n/ko.json | 2 + src/lib/server/oidc/logout.ts | 25 ++++++++++ src/lib/server/oidc/role-change.ts | 5 ++ src/lib/server/validation.ts | 48 +++++++++++++++++++ src/routes/admin/oidc-clients/+page.server.ts | 10 ++-- 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8b56fdc..edf7866 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -168,6 +168,8 @@ "saml_url_invalid_format": "{{label}}: The URL format is invalid.", "saml_url_http_loopback_only": "{{label}}: http URLs are only allowed for localhost/127.0.0.1.", "saml_url_https_only": "{{label}}: Only https URLs are allowed.", + "webhook_url_https_only": "{{label}}: Only https URLs are allowed.", + "webhook_url_ssrf_host_forbidden": "{{label}}: Internal/loopback/metadata hosts are not allowed.", "ldap_metadata_host_forbidden": "Cloud metadata hosts are not allowed.", "ldap_linklocal_forbidden": "link-local (169.254/16) addresses are not allowed.", "ldap_port_invalid": "The port number is invalid.", diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index 8a468bc..d4cad3d 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -168,6 +168,8 @@ "saml_url_invalid_format": "{{label}}: URL 형식이 올바르지 않습니다.", "saml_url_http_loopback_only": "{{label}}: http URL 은 localhost/127.0.0.1 만 허용됩니다.", "saml_url_https_only": "{{label}}: https URL 만 허용됩니다.", + "webhook_url_https_only": "{{label}}: https URL 만 허용됩니다.", + "webhook_url_ssrf_host_forbidden": "{{label}}: 내부/loopback/메타데이터 호스트는 사용할 수 없습니다.", "ldap_metadata_host_forbidden": "클라우드 메타데이터 호스트는 사용할 수 없습니다.", "ldap_linklocal_forbidden": "link-local(169.254/16) 주소는 사용할 수 없습니다.", "ldap_port_invalid": "포트 번호가 올바르지 않습니다.", diff --git a/src/lib/server/oidc/logout.ts b/src/lib/server/oidc/logout.ts index e3769f6..fcaae77 100644 --- a/src/lib/server/oidc/logout.ts +++ b/src/lib/server/oidc/logout.ts @@ -14,6 +14,28 @@ import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import type { DB } from "$lib/server/db"; import { oidcClients, oidcGrants, oidcRefreshTokens } from "$lib/server/db/schema"; import { signJwt } from "$lib/server/crypto/keys"; +import { isForbiddenWebhookHost } from "$lib/server/validation"; + +/** + * ctrls M-1(SSRF): IdP 가 서버측에서 직접 fetch 하는 아웃바운드 웹훅(backchannel logout, + * role-change) URL 이 https 이고 내부/loopback/메타데이터 호스트가 아닌지 fetch 직전에 + * 재검증한다. 등록 시 검증을 우회했거나 이전에 저장된 행을 방어하는 최종 게이트. + * 위반 시 throw — 호출자는 개별 콜백 오류를 swallow 하므로 해당 타깃만 스킵된다. + */ +export function assertPublicWebhookUrl(raw: string): void { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`webhook URL invalid: ${raw}`); + } + if (parsed.protocol !== "https:") { + throw new Error(`webhook URL must be https: ${raw}`); + } + if (isForbiddenWebhookHost(parsed.hostname)) { + throw new Error(`webhook URL host is forbidden (SSRF guard): ${parsed.hostname}`); + } +} export interface BackchannelTarget { clientId: string; @@ -125,6 +147,9 @@ export async function sendOneBackchannelLogout(target: BackchannelTarget, userId const jwt = await signJwt(payload, privateKey, kid, { typ: "logout+jwt" }); const body = new URLSearchParams({ logout_token: jwt }); + // ctrls M-1(SSRF): fetch 직전 재검증(fail-closed). + assertPublicWebhookUrl(target.backchannelLogoutUri); + await fetch(target.backchannelLogoutUri, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, diff --git a/src/lib/server/oidc/role-change.ts b/src/lib/server/oidc/role-change.ts index a945bf2..a026de2 100644 --- a/src/lib/server/oidc/role-change.ts +++ b/src/lib/server/oidc/role-change.ts @@ -18,6 +18,7 @@ import { and, eq, isNotNull } from "drizzle-orm"; import type { DB } from "$lib/server/db"; import { oidcClients } from "$lib/server/db/schema"; +import { assertPublicWebhookUrl } from "$lib/server/oidc/logout"; import { signJwt } from "$lib/server/crypto/keys"; /** @@ -72,6 +73,10 @@ export async function sendRoleChangeSet(target: RoleChangeTarget, userId: string const jwt = await signJwt(payload, privateKey, kid, { typ: "secevent+jwt" }); const body = new URLSearchParams({ role_change_token: jwt }); + // ctrls M-1(SSRF): 등록 시 검증을 하더라도, 이전에 저장된 행이나 검증 우회 경로가 + // 내부 호스트로 서명된 SET 을 흘리지 않도록 fetch 직전 재검증(fail-closed). + assertPublicWebhookUrl(target.roleChangeUri); + await fetch(target.roleChangeUri, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index 3700771..7fa738b 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -34,6 +34,54 @@ export function isLinkLocalHost(hostname: string): boolean { return /^169\.254\./.test(hostname.toLowerCase()); } +/** + * ctrls M-1(SSRF): 서버측(IdP egress)에서 fetch 하는 아웃바운드 웹훅 URI 의 호스트가 + * SSRF 표적인지 검사한다. redirect_uri 같은 "브라우저가 향하는" URL 과 달리, + * backchannel/frontchannel logout·role-change URI 는 Worker 가 직접 서버측 POST 하므로 + * loopback/사설망/메타데이터 호스트는 정상 사용처가 없다 — 전부 차단(LDAP 게이트보다 강함). + * + * 주의: 호스트명이 사설 IP 로 resolve 되는 DNS-rebinding 은 리터럴 차단만으로는 막지 + * 못한다(fetch 시점 재검증으로 완화하되, 완전 차단하려면 resolve 후 IP 검사 필요). + */ +export function isForbiddenWebhookHost(hostname: string): boolean { + const h = hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); + if (isCloudMetadataHost(h) || isLinkLocalHost(h) || isLoopbackHost(hostname)) return true; + // IPv4 사설/특수 대역 + if (/^127\./.test(h)) return true; // loopback /8 + if (/^10\./.test(h)) return true; // 10/8 + if (/^192\.168\./.test(h)) return true; // 192.168/16 + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // 172.16/12 + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true; // 100.64/10 CGNAT + if (/^0\./.test(h) || h === "0.0.0.0") return true; // "this host" + // IPv6 특수 대역 + if (h === "::" || h === "::1") return true; + if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 unique-local + if (/^fe80:/.test(h)) return true; // link-local + return false; +} + +/** + * 아웃바운드 웹훅 URL 검증. https 강제 + SSRF 호스트 차단. + * @param label 에러 메시지 접두사(예: "Role-change URI"). + */ +export function validateWebhookUrl(value: string, label: string): ValidationResult { + if (!value) return { ok: true }; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return { ok: false, reason: { key: "saml_url_invalid_format", params: { label } } }; + } + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + if (scheme !== "https") { + return { ok: false, reason: { key: "webhook_url_https_only", params: { label } } }; + } + if (isForbiddenWebhookHost(parsed.hostname)) { + return { ok: false, reason: { key: "webhook_url_ssrf_host_forbidden", params: { label } } }; + } + return { ok: true }; +} + /** * SAML ACS/SLO 등 SP URL 검증. 빈 값은 통과(선택 필드). * https 만 허용하되, http 는 loopback 호스트에 한해 허용(개발/내부). diff --git a/src/routes/admin/oidc-clients/+page.server.ts b/src/routes/admin/oidc-clients/+page.server.ts index 82d6e37..8bbc301 100644 --- a/src/routes/admin/oidc-clients/+page.server.ts +++ b/src/routes/admin/oidc-clients/+page.server.ts @@ -6,7 +6,7 @@ import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { oidcClients } from "$lib/server/db/schema"; import { hashClientSecret } from "$lib/server/oidc/client"; import { ensureCsrfToken, isValidCsrf } from "$lib/server/auth/csrf"; -import { isLoopbackHost } from "$lib/server/validation"; +import { isLoopbackHost, validateWebhookUrl } from "$lib/server/validation"; import { adminError, requireFormId } from "$lib/server/admin/errors"; import type { Locale } from "$lib/i18n/core"; @@ -80,10 +80,12 @@ function validateUriList(raw: string, label: string, locale: Locale, opts: { all return { ok: true, json: JSON.stringify(list) }; } +// frontchannel/backchannel logout·role-change URI 검증. 이 세 URI 는 IdP 가 서버측에서 +// 직접 fetch 하는 아웃바운드 웹훅이므로(브라우저 redirect_uri 와 다름) https 강제 + +// SSRF 호스트 차단(loopback/사설망/메타데이터)을 적용한다. ctrls M-1(SSRF). function validateSingleUri(value: string, label: string, locale: Locale): { ok: true } | { ok: false; reason: string } { - if (!value) return { ok: true }; - const r = validateClientUri(value, locale); - if (!r.ok) return { ok: false, reason: `${label}: ${r.reason}` }; + const r = validateWebhookUrl(value, label); + if (!r.ok) return { ok: false, reason: adminError(locale, r.reason.key, r.reason.params) }; return { ok: true }; } From d132ff33ddad7eaf6b78688e2b2206944de6f498 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:06:42 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix(security):=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20DoS=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20"?= =?UTF-8?q?=EC=98=AC=EB=B0=94=EB=A5=B8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=EB=8A=94=20=ED=95=AD=EC=83=81=20=ED=86=B5=EA=B3=BC"?= =?UTF-8?q?=20(M-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 단위 잠금(login:user:)이 인증 전에 하드 차단되어, 무인증 공격자가 임의 username 으로 15분당 10회 실패를 유발하면 올바른 비밀번호를 가진 피해자(관리자 포함)까지 로그인 불가로 만들 수 있었다. 잠금 상태여도 인증을 수행하고, 인증까지 실패한 경우에만 잠금 응답(429)으로 전환하도록 변경했다. 정상 사용자는 user 가 채워져 실패 분기에 도달하지 않으므로 절대 차단되지 않는다. 브루트포스 방어(오답 시 잠금 + IP당 10회/15분)는 그대로 유지된다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/(auth)/login/+page.server.ts | 52 +++++++++++++------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 471b099..1276146 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -139,30 +139,21 @@ export const actions: Actions = { }); } - // 계정 단위 잠금(S2): 인증 시도 전에 잠금 여부를 증가 없이 조회해 조기 차단한다. - // (LDAP/로컬 인증 이전이라 두 경로 모두 보호되고, 잠긴 계정은 올바른 비밀번호로도 - // 진입할 수 없어 scrypt 비용 낭비와 열거 오라클을 함께 차단한다.) - // peekRateLimit: 증가 없이 조회만. checkRateLimit 은 호출 시 선증가시켜 "성공 미카운트 + - // 실패 시에만 기록" 요건과 충돌하므로, 잠금 판정은 peek 로 하고 실제 기록은 실패 분기에서만 한다. + // 계정 단위 잠금(S2, ctrls M-2): 동일 계정에 대한 연속 실패를 제한하되, 무인증 + // 공격자가 임의 username 으로 실패를 유발해 "올바른 비밀번호를 가진 피해자"를 + // 로그인 불가로 만드는 DoS 를 막는다. + // + // 핵심 원칙 — "올바른 비밀번호는 항상 통과": 잠금 상태여도 인증을 건너뛰지 않고, + // 인증까지 실패한 경우에만 잠금 응답(429)으로 전환한다. 정상 사용자는 아래에서 + // user 가 채워져 실패 분기에 도달하지 않으므로 절대 차단되지 않는다. (기존엔 인증 + // 전에 하드 차단해 올바른 비밀번호도 거부됐다.) + // + // trade-off: 잠긴 계정의 오답 시도는 이제 인증(scrypt/LDAP)을 한 번 수행한다. + // 이 비용은 이미 IP당 10회/15분(login:${ipKey}) 제한으로 상한이 걸려 있다. + // peekRateLimit 은 증가 없이 조회만 한다(성공 미카운트, 실패 분기에서만 기록). const userLockKey = `login:user:${username}`; const lock = await peekRateLimit(rateLimitStore, userLockKey, { windowMs: USER_LOCK_WINDOW_MS, limit: USER_LOCK_LIMIT }); - if (!lock.allowed) { - await recordAuditEvent(db, { - tenantId: tenant.id, - kind: "login", - outcome: "failure", - ip: requestMetadata.ip, - userAgent: requestMetadata.userAgent, - detail: { username, reason: "account_locked" }, - }); - const msg = translate(locale, "login.err_account_locked", { minutes: Math.ceil(lock.retryAfterMs / 60000) }); - return fail(429, { - username, - redirectTo, - error: msg, - skinHtml: await resolveSkinForAction(event, msg, redirectTo), - }); - } + const accountLocked = !lock.allowed; // LDAP 프로바이더가 설정된 경우 먼저 시도 const [ldapProvider] = await db @@ -270,8 +261,7 @@ export const actions: Actions = { if (!user) { // 실패 시에만 카운트(성공은 미카운트). 미존재/존재-오답 모두 이 분기를 타므로 - // 동일하게 기록되어 열거 오라클을 만들지 않는다. 임계 초과는 다음 요청의 - // accountLockStatus 조기 차단에서 반영된다. + // 동일하게 기록되어 열거 오라클을 만들지 않는다. await checkRateLimit(rateLimitStore, userLockKey, { windowMs: USER_LOCK_WINDOW_MS, limit: USER_LOCK_LIMIT }); await recordAuditEvent(db, { @@ -280,9 +270,21 @@ export const actions: Actions = { outcome: "failure", ip: requestMetadata.ip, userAgent: requestMetadata.userAgent, - detail: { username }, + detail: accountLocked ? { username, reason: "account_locked" } : { username }, }); + // 잠금 상태에서 인증까지 실패한 경우에만 잠금 응답. 올바른 비밀번호였다면 위에서 + // user 가 채워져 이 분기에 오지 않으므로 정상 사용자는 절대 차단되지 않는다(DoS 방지). + if (accountLocked) { + const msg = translate(locale, "login.err_account_locked", { minutes: Math.ceil(lock.retryAfterMs / 60000) }); + return fail(429, { + username, + redirectTo, + error: msg, + skinHtml: await resolveSkinForAction(event, msg, redirectTo), + }); + } + const msg = translate(locale, "login.err_invalid_credentials"); return fail(400, { username, From a6b641edc3943a395d521ab0e1025497aa767ce8 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:08:03 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix(security):=20admin=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=97=90=20=EA=B3=84=EC=A0=95=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=9E=A0=EA=B8=88=20=EC=B6=94=EA=B0=80=20(M-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin 로그인이 IP당 제한(admin-login:)만 있어, 다수 IP 를 가진 공격자가 admin 계정에 패스워드 스프레이를 할 수 있었다(사용자 로그인엔 계정별 잠금이 있었으나 admin 경로엔 없었다). admin-login:user: 계정별 잠금(15분/10회)을 추가했다. 사용자 로그인(M-2)과 동일한 "올바른 비밀번호는 항상 통과" 모델을 적용해 잠금이 정상 관리자를 차단하지 않는다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/i18n/en.json | 1 + src/lib/i18n/ko.json | 1 + src/routes/admin/login/+page.server.ts | 25 +++++++++++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index edf7866..2bea0d1 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -153,6 +153,7 @@ "login_mfa_config": "MFA configuration error: IDP_SIGNING_KEY_SECRET is not configured.", "login_mfa_required": "Administrator accounts must have MFA (OTP) configured. Please register MFA first.", "login_rate_limit": "Too many login attempts. Please try again in {{minutes}} minute(s).", + "login_account_locked": "This account is temporarily locked due to repeated failures. Please try again in {{minutes}} minute(s).", "team_name_required": "Please enter a team name.", "part_name_required": "Please enter a part name.", "position_name_required": "Please enter a position name.", diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index d4cad3d..525ebbd 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -153,6 +153,7 @@ "login_mfa_config": "MFA 설정 오류: IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다.", "login_mfa_required": "관리자 계정은 MFA(OTP) 설정이 필수입니다. 먼저 MFA를 등록해 주세요.", "login_rate_limit": "로그인 시도가 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", + "login_account_locked": "반복된 실패로 계정이 일시적으로 잠겼습니다. {{minutes}}분 후 다시 시도해 주세요.", "team_name_required": "팀명을 입력해 주세요.", "part_name_required": "파트명을 입력해 주세요.", "position_name_required": "직급명을 입력해 주세요.", diff --git a/src/routes/admin/login/+page.server.ts b/src/routes/admin/login/+page.server.ts index 0d91e92..90e1806 100644 --- a/src/routes/admin/login/+page.server.ts +++ b/src/routes/admin/login/+page.server.ts @@ -5,10 +5,16 @@ import { requireDbContext } from "$lib/server/auth/guards"; import { authenticateLocalUser, hasTotpCredential, normalizeUsername } from "$lib/server/auth/users"; import { createMfaPendingToken, MFA_PENDING_COOKIE } from "$lib/server/auth/mfa"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; -import { checkRateLimit } from "$lib/server/ratelimit"; +import { checkRateLimit, peekRateLimit } from "$lib/server/ratelimit"; import { translate } from "$lib/i18n/server"; import { adminError } from "$lib/server/admin/errors"; +// ctrls M-8: admin 로그인도 사용자 로그인과 동일하게 계정 단위 잠금을 둔다(15분/10회). +// 다수 IP 에서의 admin 계정 패스워드 스프레이를 차단한다. 사용자 로그인(M-2)과 동일한 +// "올바른 비밀번호는 항상 통과" 모델 — 잠금 상태여도 인증을 수행하고 실패한 경우에만 잠금 응답. +const ADMIN_LOCK_WINDOW_MS = 15 * 60 * 1000; +const ADMIN_LOCK_LIMIT = 10; + function sanitizeRedirectTarget(target: string | null): string | null { if (!target) return null; let decoded: string; @@ -73,18 +79,33 @@ export const actions: Actions = { }); } + // 계정 단위 잠금(M-8). 증가 없이 조회만 하고(성공 미카운트), 실패 분기에서만 기록한다. + const userLockKey = `admin-login:user:${username}`; + const lock = await peekRateLimit(rateLimitStore, userLockKey, { windowMs: ADMIN_LOCK_WINDOW_MS, limit: ADMIN_LOCK_LIMIT }); + const accountLocked = !lock.allowed; + const user = await authenticateLocalUser(db, tenant.id, username, password); if (!user) { + await checkRateLimit(rateLimitStore, userLockKey, { windowMs: ADMIN_LOCK_WINDOW_MS, limit: ADMIN_LOCK_LIMIT }); await recordAuditEvent(db, { tenantId: tenant.id, kind: "login", outcome: "failure", ip: requestMetadata.ip, userAgent: requestMetadata.userAgent, - detail: { username, via: "admin-login" }, + detail: accountLocked ? { username, via: "admin-login", reason: "account_locked" } : { username, via: "admin-login" }, }); + // 올바른 비밀번호였다면 user 가 채워져 이 분기에 오지 않으므로 정상 관리자는 차단되지 않는다. + if (accountLocked) { + return fail(429, { + username, + redirectTo, + error: adminError(locale, "login_account_locked", { minutes: Math.ceil(lock.retryAfterMs / 60000) }), + }); + } + return fail(400, { username, redirectTo, From 5ee7be796210a0d141c9f8b4a6d81405bf2ef0f6 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:10:22 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(security):=20=ED=83=88=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=8B=9C=20OIDC=20refresh=20token=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=20+=20refresh=20grant=20=EC=A0=91=EA=B7=BC=EA=B6=8C?= =?UTF-8?q?=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20(M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assignment 회수(revokeAssignment)가 role-change SET(roles:[])만 발행하고 refresh token/세션은 폐기하지 않아, 탈권한된 사용자가 보유 중인 refresh token 으로 최대 30일간 access/id token 을 계속 재발급받고 userinfo 를 조회할 수 있었다(비밀번호 리셋·계정삭제 경로는 이미 refresh token 을 폐기하는데 assignment 회수만 누락). - revokeAssignment: OIDC 타깃일 때 revokeRefreshTokenFamily 로 해당 클라이언트의 이 사용자 refresh token 을 폐기. - token refresh grant: 매 refresh 마다 hasServiceAccess 재검증(authorize 와 동일 게이트) — 놓친 폐기 경로가 있어도 access token TTL(5분) 안에 fail-closed. allowAllUsers 클라이언트는 예외. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/admin/user-actions/service.ts | 16 ++++++++++++++++ src/routes/oidc/token/+server.ts | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/server/admin/user-actions/service.ts b/src/lib/server/admin/user-actions/service.ts index ee4e81c..75d0050 100644 --- a/src/lib/server/admin/user-actions/service.ts +++ b/src/lib/server/admin/user-actions/service.ts @@ -10,6 +10,7 @@ import { getActiveAssignment } from "$lib/server/access/service-permissions"; import { getActiveSigningKey } from "$lib/server/crypto/keys"; import { resolveIssuerUrl } from "$lib/server/auth/runtime"; import { getRoleChangeTarget, sendRoleChangeSet } from "$lib/server/oidc/role-change"; +import { revokeRefreshTokenFamily } from "$lib/server/oidc/refresh"; // 사용자 상세 페이지의 서비스 권한(assignment) 액션. type UserActionEvent = RequestEvent<{ id: string }, "/admin/users/[id]">; @@ -218,6 +219,21 @@ export async function revokeAssignment(event: UserActionEvent) { // 회수 → RP 에 roles: [] push (oidc + role_change_uri 설정 시). 삭제 후이므로 active role 없음. if (target) { + // ctrls M-3: 탈권한(assignment 회수) 시 해당 OIDC 클라이언트에 대한 이 사용자의 활성 + // refresh token 을 폐기한다. role-change SET 은 계약상 세션을 끊지 않으므로, 이것이 + // 없으면 탈권한 사용자가 보유 중인 refresh token 으로 최대 30일간 access/id token 을 + // 계속 재발급받을 수 있었다. (access token 은 자체완결형 5분 TTL — 최대 5분 내 만료. + // refresh grant 의 hasServiceAccess 재검증(token/+server.ts)이 이중 방어.) + if (target.serviceType === "oidc") { + const [oc] = await db + .select({ clientId: oidcClients.clientId }) + .from(oidcClients) + .where(and(eq(oidcClients.id, target.serviceRefId), eq(oidcClients.tenantId, tenant.id))) + .limit(1); + if (oc) { + await revokeRefreshTokenFamily(db, tenant.id, params.id, oc.clientId); + } + } await emitRoleChangeSet(event, db, tenant.id, params.id, target.serviceType, target.serviceRefId); } diff --git a/src/routes/oidc/token/+server.ts b/src/routes/oidc/token/+server.ts index 1a921c8..11aab12 100644 --- a/src/routes/oidc/token/+server.ts +++ b/src/routes/oidc/token/+server.ts @@ -12,7 +12,7 @@ import { findAndConsumeGrant } from "$lib/server/oidc/grant"; import { verifyPkce } from "$lib/server/oidc/pkce"; import { issueRefreshToken, rotateRefreshToken, revokeRefreshTokenFamily } from "$lib/server/oidc/refresh"; import { generateAccessToken, getActiveSigningKey, signJwt } from "$lib/server/crypto/keys"; -import { getActiveAssignment, parseAssignmentAttributes } from "$lib/server/access/service-permissions"; +import { getActiveAssignment, hasServiceAccess, parseAssignmentAttributes } from "$lib/server/access/service-permissions"; import { getUserMembership, membershipToGroups } from "$lib/server/org/membership"; import { resolveIssuerUrl } from "$lib/server/auth/runtime"; import type { DB } from "$lib/server/db"; @@ -369,6 +369,24 @@ export const POST: RequestHandler = async (event) => { return tokenError("invalid_grant", "사용자를 찾을 수 없습니다."); } + // ctrls M-3: 매 refresh 마다 서비스 접근 권한을 재확인한다(authorize 와 동일 게이트). + // assignment 회수 시 refresh token 을 직접 폐기하지만(service.ts), 놓친 폐기 경로가 + // 있어도 access token TTL(5분) 안에 fail-closed 되도록 이중 방어한다. allowAllUsers + // 클라이언트는 매핑 없이 전체 허용이므로 예외. + if (!client.allowAllUsers) { + const stillAllowed = await hasServiceAccess(db, { + tenantId: tenant.id, + userId: user.id, + serviceType: "oidc", + serviceRefId: client.id, + }); + if (!stillAllowed) { + await revokeRefreshTokenFamily(db, tenant.id, user.id, clientId); + await recordTokenFailure(clientId, "invalid_grant", "서비스 접근 권한이 회수됨"); + return tokenError("invalid_grant", "서비스 접근 권한이 없습니다. 다시 로그인해 주세요."); + } + } + const authTimeSec = sessionRow ? Math.floor(sessionRow.createdAt.getTime() / 1000) : Math.floor(record.createdAt.getTime() / 1000); const { idToken, accessToken } = await buildTokens({ db, From d83737935b66e37169b306a86ddbbe54a95561f5 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:12:16 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(security):=20MFA=20=EB=B0=B1=EC=97=85?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=AC=B4=EC=97=BC=20SHA-256=20?= =?UTF-8?q?=E2=86=92=20scrypt(salted)=20=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(?= =?UTF-8?q?M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백업코드가 무염 단일 SHA-256(fast hash)으로 저장되어, DB 유출 시 ~2^40 엔트로피의 코드를 전 사용자에 걸쳐 병렬 GPU 크랙 → 대규모 오프라인 MFA 우회가 가능했다. TOTP 시크릿은 AES-GCM 암호화되지만 백업코드가 같은 테이블의 약한 고리였다. 패스워드와 동일한 scrypt(salted, memory-hard) KDF 를 재사용하도록 hashBackupCode 를 변경했다. verifyBackupCode 는 scrypt/argon2/pbkdf2 형식은 verifyPassword 로, 레거시 무염 SHA-256 hex 는 하위호환 상수시간 비교로 처리한다(일회성이라 자연 소멸). trade-off: 검증이 저장된 미사용 코드를 순회하므로 시도당 최대 ~1초지만, 백업코드 로그인은 드물고 MFA 라우트 rate-limit 으로 상한이 걸려 있다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/auth/totp.ts | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/lib/server/auth/totp.ts b/src/lib/server/auth/totp.ts index 7578361..a5f8122 100644 --- a/src/lib/server/auth/totp.ts +++ b/src/lib/server/auth/totp.ts @@ -3,9 +3,12 @@ * * - TOTP: WebCrypto HMAC-SHA-1, 30초 스텝, 6자리, ±1 윈도우 * - 시크릿 암호화: AES-256-GCM + HKDF (IDP_SIGNING_KEY_SECRET 재사용) - * - 백업 코드: 10개 × 8자리 alphanumeric, SHA-256 단방향 해시 + * - 백업 코드: 10개 × 8자리 alphanumeric, scrypt(salted, memory-hard) 단방향 해시 + * (레거시 무염 SHA-256 해시는 검증 시 하위호환 처리) */ +import { hashPassword, verifyPassword } from "$lib/server/auth/password"; + // ── Base32 (RFC 4648) ───────────────────────────────────────────────────────── const BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; @@ -220,25 +223,35 @@ export function generateBackupCodes(): string[] { } /** - * 백업 코드를 SHA-256 해시로 저장용 변환. - * 코드 자체가 충분한 엔트로피를 가지므로 salt 없이 사용. + * 백업 코드를 저장용 해시로 변환한다. ctrls M-4. + * + * 무염 SHA-256(fast hash)은 DB 유출 시 ~2^40 엔트로피의 코드를 전 사용자에 걸쳐 병렬로 + * GPU 크랙할 수 있어(오프라인 MFA 우회), 패스워드와 동일한 scrypt(salted, memory-hard) + * KDF 를 재사용한다. 코드는 대문자 정규화 후 해싱한다. */ export async function hashBackupCode(code: string): Promise { - const enc = new TextEncoder(); - const digest = await crypto.subtle.digest("SHA-256", enc.encode(code.toUpperCase())); - return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); + return hashPassword(code.toUpperCase()); } /** - * 입력한 코드가 저장된 해시와 일치하는지 검증. - * XOR 기반 상수 시간 비교로 타이밍 공격을 방지한다. + * 입력한 코드가 저장된 해시와 일치하는지 검증한다. + * - scrypt/argon2/pbkdf2 형식: 패스워드 KDF(verifyPassword)로 상수시간 검증. + * - 레거시 무염 SHA-256 hex: 하위호환을 위해 상수시간 비교(이미 저장된 코드 대상). + * 백업 코드는 일회성이라 사용 즉시 소진되므로 레거시 해시는 자연 소멸한다. */ export async function verifyBackupCode(code: string, storedHash: string): Promise { - const hash = await hashBackupCode(code); - if (hash.length !== storedHash.length) return false; + if (storedHash.startsWith("scrypt$") || storedHash.startsWith("$argon2") || storedHash.startsWith("pbkdf2$")) { + const { valid } = await verifyPassword(code.toUpperCase(), storedHash); + return valid; + } + // 레거시 무염 SHA-256 hex. + const enc = new TextEncoder(); + const digest = await crypto.subtle.digest("SHA-256", enc.encode(code.toUpperCase())); + const legacy = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); + if (legacy.length !== storedHash.length) return false; let diff = 0; - for (let i = 0; i < hash.length; i++) { - diff |= hash.charCodeAt(i) ^ storedHash.charCodeAt(i); + for (let i = 0; i < legacy.length; i++) { + diff |= legacy.charCodeAt(i) ^ storedHash.charCodeAt(i); } return diff === 0; } From ce1819f2c79a30b8548c7c341f4ae93c8a53bfa2 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:14:58 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(security):=20=EA=B3=84=EC=A0=95=20sel?= =?UTF-8?q?f-service=20MFA=20step-up=20=EC=8A=A4=EB=A1=9C=ED=8B=80=20+=20r?= =?UTF-8?q?eplay=20=EB=B0=A9=EC=96=B4=20(M-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account/mfa 의 confirm/delete/regenerate 와 danger-zone 삭제 step-up 에 rate limit 이 없어, 세션 탈취자가 6자리 TOTP 를 무제한 브루트포스해 피해자의 MFA 를 제거/회전(지속성 확보)할 수 있었다. 또 delete/regenerate 는 lastUsedStep 없이 검증해 다른 엔드포인트에서 쓴 코드를 replay 할 수 있었다. - 세 액션 + danger-zone 에 mfa-stepup: 공유 스로틀(5분/10회) 추가 — 엔드포인트를 옮겨가며 횟수를 우회하지 못하게 키를 공유. - delete/regenerate 의 verifyTotp 에 lastUsedStep(counter) 바인딩, regenerate 는 성공 시 counter 갱신(credential 유지). 로그인 /mfa·/api/totp/* 와 정합. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../account/danger-zone/+page.server.ts | 18 +++++++-- src/routes/account/mfa/+page.server.ts | 40 ++++++++++++++++--- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/routes/account/danger-zone/+page.server.ts b/src/routes/account/danger-zone/+page.server.ts index b3474e3..4eb0995 100644 --- a/src/routes/account/danger-zone/+page.server.ts +++ b/src/routes/account/danger-zone/+page.server.ts @@ -14,6 +14,12 @@ import { TOTP_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { dispatchSecurityAlert } from "$lib/server/security-notify"; import { credentials, users } from "$lib/server/db/schema"; import { translate } from "$lib/i18n/server"; +import { checkRateLimit } from "$lib/server/ratelimit"; + +// ctrls M-5: 계정 삭제 step-up(비밀번호/TOTP) 브루트포스 방어. account/mfa 와 동일한 +// mfa-stepup: 키를 공유해 공격자가 엔드포인트를 옮겨가며 시도횟수를 우회하지 못하게 한다. +const STEPUP_WINDOW_MS = 5 * 60 * 1000; +const STEPUP_LIMIT = 10; // 소프트 삭제 유예기간 = 30일. 이 기간 내 로그인하면 계정을 복구할 수 있고, 경과하면 GC 가 // 하드 삭제한다. (login 복구 흐름·gc.ts 하드삭제 조건과 짝을 이룬다.) @@ -47,18 +53,24 @@ export const actions: Actions = { const { locals } = event; if (!locals.user) throw redirect(303, "/login"); - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); const locale = locals.locale; const user = locals.user; + const requestMetadata = getRequestMetadata(event); + + // ctrls M-5: step-up 브루트포스 방어 (5분/10회, 사용자 단위) + const rl = await checkRateLimit(rateLimitStore, `mfa-stepup:${user.id}`, { windowMs: STEPUP_WINDOW_MS, limit: STEPUP_LIMIT }); + if (!rl.allowed) { + return fail(429, { error: translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }) }); + } + const formData = await event.request.formData(); const password = String(formData.get("password") ?? ""); const totpCode = String(formData.get("totp") ?? "") .trim() .replace(/\s/g, ""); - const requestMetadata = getRequestMetadata(event); - // ── step-up 재인증 (비밀번호 또는 TOTP) ───────────────────────────────── // 세션 탈취 공격자가 정당 소유자의 계정을 삭제하지 못하도록 재인증을 강제한다. // 비밀번호 크레덴셜이 있으면 비밀번호를, 없거나 TOTP 코드를 제출하면 TOTP 를 검증한다. diff --git a/src/routes/account/mfa/+page.server.ts b/src/routes/account/mfa/+page.server.ts index ab34582..c7affa4 100644 --- a/src/routes/account/mfa/+page.server.ts +++ b/src/routes/account/mfa/+page.server.ts @@ -9,10 +9,17 @@ import { TOTP_CREDENTIAL_TYPE, BACKUP_CODE_CREDENTIAL_TYPE } from "$lib/server/a import { credentials } from "$lib/server/db/schema"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; import { dispatchSecurityAlert } from "$lib/server/security-notify"; +import { checkRateLimit } from "$lib/server/ratelimit"; const TOTP_SETUP_COOKIE = "idp_totp_setup"; const TOTP_SETUP_TTL_MS = 10 * 60 * 1000; // 10분 +// ctrls M-5: 계정 self-service MFA step-up(confirm/delete/regenerate) 브루트포스/replay 방어. +// 세션 탈취자가 스로틀 없는 6자리 TOTP 를 무제한 시도해 MFA 를 제거/회전하는 것을 막는다. +// 로그인 /mfa·/api/totp/* 와 동일한 5분/10회 상한. +const MFA_STEPUP_WINDOW_MS = 5 * 60 * 1000; +const MFA_STEPUP_LIMIT = 10; + // ── 등록 중 시크릿 임시 저장용 서명 쿠키 ──────────────────────────────────── async function createSetupToken(base32Secret: string, signingKeySecret: string): Promise { @@ -168,6 +175,14 @@ export const actions: Actions = { return fail(400, { confirm: true, error: "인증 코드를 입력해 주세요." }); } + const { db, tenant, rateLimitStore } = requireDbContext(locals); + + // ctrls M-5: step-up 브루트포스 방어 (5분/10회, 사용자 단위) + const rl = await checkRateLimit(rateLimitStore, `mfa-stepup:${locals.user.id}`, { windowMs: MFA_STEPUP_WINDOW_MS, limit: MFA_STEPUP_LIMIT }); + if (!rl.allowed) { + return fail(429, { confirm: true, error: `시도가 너무 많습니다. ${Math.ceil(rl.retryAfterMs / 60000)}분 후 다시 시도해 주세요.` }); + } + const valid = await verifyTotp(code, plainSecret); if (!valid) { return fail(400, { @@ -176,8 +191,6 @@ export const actions: Actions = { }); } - const { db, tenant } = requireDbContext(locals); - // 이미 등록된 경우 방지 const [existing] = await db .select({ id: credentials.id }) @@ -250,7 +263,13 @@ export const actions: Actions = { return fail(400, { delete: true, error: "현재 TOTP 코드를 입력해 주세요." }); } - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); + + // ctrls M-5: step-up 브루트포스 방어 (5분/10회, 사용자 단위) + const rl = await checkRateLimit(rateLimitStore, `mfa-stepup:${locals.user.id}`, { windowMs: MFA_STEPUP_WINDOW_MS, limit: MFA_STEPUP_LIMIT }); + if (!rl.allowed) { + return fail(429, { delete: true, error: `시도가 너무 많습니다. ${Math.ceil(rl.retryAfterMs / 60000)}분 후 다시 시도해 주세요.` }); + } const [totpCred] = await db .select() @@ -263,7 +282,8 @@ export const actions: Actions = { } const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, locals.user!.id)); - const matchedStep = await verifyTotp(code, plainSecret); + // ctrls M-5: lastUsedStep 바인딩으로 코드 replay(다른 엔드포인트에서 이미 쓴 코드 재사용) 차단. + const matchedStep = await verifyTotp(code, plainSecret, totpCred.counter ?? undefined); if (matchedStep === null) { return fail(400, { delete: true, error: "인증 코드가 올바르지 않습니다." }); } @@ -307,7 +327,13 @@ export const actions: Actions = { return fail(400, { regenerate: true, error: "현재 TOTP 코드를 입력해 주세요." }); } - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); + + // ctrls M-5: step-up 브루트포스 방어 (5분/10회, 사용자 단위) + const rl = await checkRateLimit(rateLimitStore, `mfa-stepup:${locals.user.id}`, { windowMs: MFA_STEPUP_WINDOW_MS, limit: MFA_STEPUP_LIMIT }); + if (!rl.allowed) { + return fail(429, { regenerate: true, error: `시도가 너무 많습니다. ${Math.ceil(rl.retryAfterMs / 60000)}분 후 다시 시도해 주세요.` }); + } // TOTP 등록 여부 확인 및 코드 검증 const [totpCred] = await db @@ -321,10 +347,12 @@ export const actions: Actions = { } const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, locals.user!.id)); - const matchedStep = await verifyTotp(code, plainSecret); + // ctrls M-5: lastUsedStep 바인딩으로 코드 replay 차단. credential 이 유지되므로 성공 시 counter 갱신. + const matchedStep = await verifyTotp(code, plainSecret, totpCred.counter ?? undefined); if (matchedStep === null) { return fail(400, { regenerate: true, error: "인증 코드가 올바르지 않습니다." }); } + await db.update(credentials).set({ counter: matchedStep }).where(eq(credentials.id, totpCred.id)); // 기존 백업 코드 전체 삭제 await db.delete(credentials).where(and(eq(credentials.userId, locals.user.id), eq(credentials.type, BACKUP_CODE_CREDENTIAL_TYPE))); From 680c9a3e8401996285f96c266da6db19cec6c349 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Wed, 8 Jul 2026 19:16:35 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(security):=20skin=20sanitizer=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20style=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=86=8D=EC=84=B1=20=EB=AC=B4=EB=A0=A5?= =?UTF-8?q?=ED=99=94=20(M-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizer 가 denylist 이며