diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8b56fdc..ded41f3 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.", @@ -168,6 +169,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.", @@ -745,6 +748,7 @@ }, "errors": { "rate_limit": "Too many requests. Please try again in {{minutes}} minute(s).", + "password_too_long": "Password must be at most {{max}} characters.", "db_not_ready": "The D1 binding \"DB\" is not ready. Please run in a Wrangler preview/dev environment." }, "email": { diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index 8a468bc..0a13743 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": "직급명을 입력해 주세요.", @@ -168,6 +169,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": "포트 번호가 올바르지 않습니다.", @@ -745,6 +748,7 @@ }, "errors": { "rate_limit": "요청이 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", + "password_too_long": "비밀번호는 최대 {{max}}자까지 가능합니다.", "db_not_ready": "D1 binding \"DB\" 가 준비되지 않았습니다. Wrangler preview/dev 환경에서 실행해 주세요." }, "email": { diff --git a/src/lib/server/admin/crud-factory.ts b/src/lib/server/admin/crud-factory.ts index cb482d3..539201f 100644 --- a/src/lib/server/admin/crud-factory.ts +++ b/src/lib/server/admin/crud-factory.ts @@ -110,7 +110,10 @@ export function createAdminCrudRoute) } as typeof config.table.$inferInsert); + // ctrls LOW: tenantId 를 spread 뒤에 두어 form 이 주입한 tenantId 가 서버 값을 덮어쓰지 + // 못하게 한다(cross-tenant write 방지). 현재 zod 스키마가 unknown 키를 strip 하지만 + // 방어를 명시적으로 보장한다. + await db.insert(config.table).values({ ...(values as Record), tenantId: tenant.id } as typeof config.table.$inferInsert); const meta = getRequestMetadata(event); await recordAuditEvent(db, { 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/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, diff --git a/src/lib/server/auth/password.ts b/src/lib/server/auth/password.ts index 154b6ef..a36d62e 100644 --- a/src/lib/server/auth/password.ts +++ b/src/lib/server/auth/password.ts @@ -19,6 +19,10 @@ import { verifyEncoded } from "@hicaru/argon2-pure.js"; // ── scrypt ──────────────────────────────────────────────────────────────────── +// ctrls LOW: 서버측 비밀번호 최대 길이. 극단적으로 긴 입력이 KDF/HMAC 자원을 낭비하는 것을 +// 막는다(클라이언트는 64 로 제한하지만 API/curl 직접 호출 방어). 신규 가입/재설정/초대 수락 공용. +export const MAX_PASSWORD_LENGTH = 128; + const SCRYPT_N = 32768; // 2^15 const SCRYPT_R = 8; const SCRYPT_P = 3; diff --git a/src/lib/server/auth/service-token.ts b/src/lib/server/auth/service-token.ts index 55bf057..7872e95 100644 --- a/src/lib/server/auth/service-token.ts +++ b/src/lib/server/auth/service-token.ts @@ -7,7 +7,7 @@ import type { RuntimeConfig } from "./runtime"; * * 토큰 미설정 (개발/실수) 시 503 — 인증 우회 자동 거부. */ -export function requireServiceToken(request: Request, config: RuntimeConfig): void { +export async function requireServiceToken(request: Request, config: RuntimeConfig): Promise { const expected = config.dispatcherServiceToken; if (!expected) { throw error(503, "DISPATCHER_SERVICE_TOKEN 미설정 — service API 비활성"); @@ -20,16 +20,20 @@ export function requireServiceToken(request: Request, config: RuntimeConfig): vo } const provided = match[1]; - if (!timingSafeEqual(provided, expected)) { + if (!(await timingSafeEqualStr(provided, expected))) { throw error(401, "Invalid service token"); } } -function timingSafeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; +// ctrls LOW: 원문 문자열을 직접 비교하면 길이 불일치 조기 반환으로 토큰 길이가 타이밍으로 +// 누출되고, JS 엔진 문자열 비교의 상수시간성도 보장되지 않는다. 양쪽을 고정 길이 SHA-256 +// 다이제스트로 만든 뒤 상수시간 비교한다(길이 무관, 32바이트 고정). +async function timingSafeEqualStr(a: string, b: string): Promise { + const enc = new TextEncoder(); + const [da, db] = await Promise.all([crypto.subtle.digest("SHA-256", enc.encode(a)), crypto.subtle.digest("SHA-256", enc.encode(b))]); + const ua = new Uint8Array(da); + const ub = new Uint8Array(db); let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - } + for (let i = 0; i < ua.length; i++) diff |= ua[i] ^ ub[i]; return diff === 0; } 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; } 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); }); 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/saml/slo.ts b/src/lib/server/saml/slo.ts index aa613ed..7e735fd 100644 --- a/src/lib/server/saml/slo.ts +++ b/src/lib/server/saml/slo.ts @@ -261,6 +261,28 @@ export async function parseSamlLogoutRequest(samlRequestB64: string): Promise { + const raw = atob(samlResponseB64); + const binary = new Uint8Array(raw.length) as Uint8Array; + for (let i = 0; i < raw.length; i++) binary[i] = raw.charCodeAt(i); + const xml = await inflateRaw(binary); + + if (// 태그는 이미 +// FORBIDDEN_TAGS 로 제거되므로 정상 skin 은 인라인 style 로 색상/폰트/여백을 주는데, +// 이는 보존하고 position/z-index/transform/inset 계열만 무력화한다(피싱 벡터 차단). +// 완벽한 CSS 리드레싱 방지는 아니지만(음수 margin 등 잔여), JS 는 CSP 로 이미 차단된 +// 상태에서 주된 오버레이 수단을 제거하는 심층 방어다. +const DANGEROUS_STYLE_PROP_RE = /(^|;)\s*(position|top|left|right|bottom|inset(?:-[a-z]+)?|z-index|transform(?:-origin)?|perspective|float|clip|clip-path)\s*:[^;]*/gi; + +function sanitizeStyleAttr(value: string): string { + return value + .replace(DANGEROUS_STYLE_PROP_RE, "$1") + .replace(/(?:\s*;\s*)+/g, ";") // 세미콜론 런 정리 + .replace(/^;|;$/g, "") + .trim(); +} + // URI 허용 prefix — http(s), data:image/font, mailto, tel, relative(/), fragment(#) const ALLOWED_URI_RE = /^(?:https?:|data:image\/|data:font\/|mailto:|tel:|\/|#)/i; @@ -71,6 +87,13 @@ export async function sanitizeSkinHtml(dirty: string): Promise { } if (URI_ATTRIBUTES.has(name) && isDangerousUri(value)) { el.removeAttribute(rawName); + continue; + } + // ctrls M-7: 인라인 style 의 오버레이/리드레싱 속성 무력화. + if (name === "style") { + const cleaned = sanitizeStyleAttr(value); + if (cleaned) el.setAttribute(rawName, cleaned); + else el.removeAttribute(rawName); } } }, 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/(auth)/accept-invite/+page.server.ts b/src/routes/(auth)/accept-invite/+page.server.ts index 39f1a8d..5a89238 100644 --- a/src/routes/(auth)/accept-invite/+page.server.ts +++ b/src/routes/(auth)/accept-invite/+page.server.ts @@ -4,7 +4,7 @@ import type { Actions, PageServerLoad } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { users, inviteTokens, credentials } from "$lib/server/db/schema"; import { hashToken } from "$lib/server/email"; -import { hashPassword } from "$lib/server/auth/password"; +import { hashPassword, MAX_PASSWORD_LENGTH } from "$lib/server/auth/password"; import { PASSWORD_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { runAtomic } from "$lib/server/db/atomic"; import { checkRateLimit } from "$lib/server/ratelimit"; @@ -58,6 +58,7 @@ export const actions: Actions = { if (!token) return fail(400, { error: translate(locale, "accept_invite.invalid_link") }); // 비밀번호 정책 재사용 — reset_password 와 동일(8자 이상, 확인 일치). if (password.length < 8) return fail(400, { error: translate(locale, "accept_invite.err_password_short") }); + if (password.length > MAX_PASSWORD_LENGTH) return fail(400, { error: translate(locale, "errors.password_too_long", { max: MAX_PASSWORD_LENGTH }) }); if (password !== confirmPassword) return fail(400, { error: translate(locale, "accept_invite.err_password_mismatch") }); const record = await lookupToken(db, tenant.id, token); 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, diff --git a/src/routes/(auth)/reset-password/+page.server.ts b/src/routes/(auth)/reset-password/+page.server.ts index ee7fe3b..a4a311d 100644 --- a/src/routes/(auth)/reset-password/+page.server.ts +++ b/src/routes/(auth)/reset-password/+page.server.ts @@ -3,7 +3,7 @@ import { eq, and, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { users, passwordResetTokens } from "$lib/server/db/schema"; -import { hashPassword } from "$lib/server/auth/password"; +import { hashPassword, MAX_PASSWORD_LENGTH } from "$lib/server/auth/password"; import { hashToken } from "$lib/server/email"; import { revokeAllUserSessions } from "$lib/server/auth/session"; import { revokeAllUserRefreshTokens } from "$lib/server/oidc/refresh"; @@ -89,6 +89,7 @@ export const actions: Actions = { if (!token) return failWithSkin(translate(locale, "reset_password.err_invalid_request")); if (password.length < 8) return failWithSkin(translate(locale, "reset_password.err_password_short")); + if (password.length > MAX_PASSWORD_LENGTH) return failWithSkin(translate(locale, "errors.password_too_long", { max: MAX_PASSWORD_LENGTH })); if (password !== confirmPassword) return failWithSkin(translate(locale, "reset_password.err_password_mismatch")); const tokenHash = await hashToken(token); diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts index 1a24c6c..f2f37b8 100644 --- a/src/routes/(auth)/signup/+page.server.ts +++ b/src/routes/(auth)/signup/+page.server.ts @@ -3,7 +3,7 @@ import { eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; import { requireDbContext } from "$lib/server/auth/guards"; -import { hashPassword } from "$lib/server/auth/password"; +import { hashPassword, MAX_PASSWORD_LENGTH } from "$lib/server/auth/password"; import { users, credentials, identities } from "$lib/server/db/schema"; import { resolve } from "$app/paths"; import { sanitizeRedirectTarget } from "$lib/server/auth/redirect"; @@ -86,6 +86,7 @@ export const actions: Actions = { if (!/^[a-z0-9_]{3,32}$/.test(username)) return failSkin(400, translate(locale, "signup.err_invalid_username")); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return failSkin(400, translate(locale, "signup.err_invalid_email")); if (password.length < 8) return failSkin(400, translate(locale, "signup.err_password_short")); + if (password.length > MAX_PASSWORD_LENGTH) return failSkin(400, translate(locale, "errors.password_too_long", { max: MAX_PASSWORD_LENGTH })); if (password !== confirmPassword) return failSkin(400, translate(locale, "signup.err_password_mismatch")); const [existingByUsername] = await db 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))); diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index b1e72c1..e5fbf8c 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -1,10 +1,12 @@ import { count, eq } from "drizzle-orm"; import type { PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { auditEvents, departments, oidcClients, positions, samlSps, signingKeys, teams, users } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + // ctrls LOW: 다른 admin load 와 동일하게 role==="admin" 을 직접 재확인한다. + // 기존엔 requireDbContext(db/tenant 존재만 확인)라 layout 게이트에만 의존했다. + const { db, tenant } = requireAdminContext(locals); const [userCount, oidcClientCount, samlSpCount, signingKeyCount, auditEventCount, deptCount, teamCount, positionCount] = await Promise.all([ db.select({ count: count() }).from(users).where(eq(users.tenantId, tenant.id)), db.select({ count: count() }).from(oidcClients).where(eq(oidcClients.tenantId, tenant.id)), 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, 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 }; } diff --git a/src/routes/api/totp/enroll/confirm/+server.ts b/src/routes/api/totp/enroll/confirm/+server.ts index 88c3332..c7b1df1 100644 --- a/src/routes/api/totp/enroll/confirm/+server.ts +++ b/src/routes/api/totp/enroll/confirm/+server.ts @@ -17,7 +17,7 @@ import { translate } from "$lib/i18n/server"; * 검증 후 영구 저장. 백업 코드 10개 생성 후 plaintext 로 응답 (한 번만 보임). */ export const POST: RequestHandler = async ({ request, locals }) => { - requireServiceToken(request, locals.runtimeConfig); + await requireServiceToken(request, locals.runtimeConfig); const { db, rateLimitStore } = requireDbContext(locals); const config = locals.runtimeConfig; diff --git a/src/routes/api/totp/enroll/init/+server.ts b/src/routes/api/totp/enroll/init/+server.ts index d768389..fd3b9e3 100644 --- a/src/routes/api/totp/enroll/init/+server.ts +++ b/src/routes/api/totp/enroll/init/+server.ts @@ -17,7 +17,7 @@ import { credentials, users } from "$lib/server/db/schema"; * 이미 등록된 사용자는 409 (운영자가 의도적으로 reset 하려면 별도 admin API). */ export const POST: RequestHandler = async ({ request, locals }) => { - requireServiceToken(request, locals.runtimeConfig); + await requireServiceToken(request, locals.runtimeConfig); const { db } = requireDbContext(locals); const body = (await request.json().catch(() => null)) as { userId?: string } | null; diff --git a/src/routes/api/totp/status/+server.ts b/src/routes/api/totp/status/+server.ts index 635067b..97eaeb0 100644 --- a/src/routes/api/totp/status/+server.ts +++ b/src/routes/api/totp/status/+server.ts @@ -9,7 +9,7 @@ import { credentials } from "$lib/server/db/schema"; * Phase 7.3 — TOTP enroll 여부 + last-used 조회. */ export const GET: RequestHandler = async ({ request, url, locals }) => { - requireServiceToken(request, locals.runtimeConfig); + await requireServiceToken(request, locals.runtimeConfig); const { db } = requireDbContext(locals); const userId = url.searchParams.get("userId")?.trim(); diff --git a/src/routes/api/totp/verify/+server.ts b/src/routes/api/totp/verify/+server.ts index 91042ac..3ce7770 100644 --- a/src/routes/api/totp/verify/+server.ts +++ b/src/routes/api/totp/verify/+server.ts @@ -15,7 +15,7 @@ import { translate } from "$lib/i18n/server"; * 검증 통과 시 credential 의 lastUsedAt 갱신. */ export const POST: RequestHandler = async ({ request, locals }) => { - requireServiceToken(request, locals.runtimeConfig); + await requireServiceToken(request, locals.runtimeConfig); const { db, rateLimitStore } = requireDbContext(locals); const config = locals.runtimeConfig; diff --git a/src/routes/api/users/lookup/+server.ts b/src/routes/api/users/lookup/+server.ts index 4397b49..0e7b3e9 100644 --- a/src/routes/api/users/lookup/+server.ts +++ b/src/routes/api/users/lookup/+server.ts @@ -4,6 +4,14 @@ import { requireServiceToken } from "$lib/server/auth/service-token"; import { requireDbContext } from "$lib/server/auth/guards"; import { users, tenants } from "$lib/server/db/schema"; import { DEFAULT_TENANT_SLUG } from "$lib/server/auth/constants"; +import { checkRateLimit } from "$lib/server/ratelimit"; +import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; + +// ctrls M-9: service-to-service lookup 의 대량 열거 상한 + 흔적. +// 정상 dispatcher 매핑 조회는 통과하되, service token 유출 시 무제한 PII 디렉터리 덤프를 +// 막는다. 초과 시 429 + 감사 로그(kind: service_lookup_throttled)로 abuse 를 탐지 가능하게 한다. +const LOOKUP_WINDOW_MS = 60 * 1000; +const LOOKUP_LIMIT = 120; // IP당 분당 120회 /** * Service-to-service user lookup. @@ -23,9 +31,10 @@ import { DEFAULT_TENANT_SLUG } from "$lib/server/auth/constants"; * 응답: `{ id, tenantId, username, email, displayName, role, status }` * 또는 404. */ -export const GET: RequestHandler = async ({ request, url, locals }) => { - requireServiceToken(request, locals.runtimeConfig); - const { db } = requireDbContext(locals); +export const GET: RequestHandler = async (event) => { + const { request, url, locals } = event; + await requireServiceToken(request, locals.runtimeConfig); + const { db, rateLimitStore } = requireDbContext(locals); const id = url.searchParams.get("id")?.trim(); const username = url.searchParams.get("username")?.trim(); @@ -39,6 +48,21 @@ export const GET: RequestHandler = async ({ request, url, locals }) => { const [tenant] = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, tenantSlug)).limit(1); if (!tenant) throw error(404, `tenant not found: ${tenantSlug}`); + // ctrls M-9: IP당 분당 상한 — 유출된 토큰으로 무제한 열거하는 것을 막고 abuse 를 남긴다. + const meta = getRequestMetadata(event); + const rl = await checkRateLimit(rateLimitStore, `svc-lookup:${meta.ipKey}`, { windowMs: LOOKUP_WINDOW_MS, limit: LOOKUP_LIMIT }); + if (!rl.allowed) { + await recordAuditEvent(db, { + tenantId: tenant.id, + kind: "service_lookup_throttled", + outcome: "failure", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { by: id ? "id" : username ? "username" : "email" }, + }); + throw error(429, "rate limited"); + } + if (id) { const [row] = await db .select() diff --git a/src/routes/oidc/end-session/+server.ts b/src/routes/oidc/end-session/+server.ts index f98fb10..0718e8f 100644 --- a/src/routes/oidc/end-session/+server.ts +++ b/src/routes/oidc/end-session/+server.ts @@ -79,6 +79,16 @@ async function resolvePostLogoutRedirect(locals: App.Locals, postLogoutRedirectU export const GET: RequestHandler = async (event) => { const { locals, url } = event; + + // ctrls LOW: drive-by 로그아웃(CSRF) 완화. RP-Initiated Logout 은 최상위 네비게이션이라 + // Sec-Fetch-Dest=document 다. /