Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c9a6e2c
fix(security): Node 배포에서 위조 가능한 cf-connecting-ip 대신 실제 소켓 주소 사용 (H-1)
mack-erel Jul 8, 2026
588e4fe
fix(security): LDAP starttls 모드에서 STARTTLS 를 실제로 협상 (H-2)
mack-erel Jul 8, 2026
6c70e7e
fix(security): OIDC 아웃바운드 웹훅 URI 에 SSRF 호스트 게이트 적용 (M-1)
mack-erel Jul 8, 2026
d132ff3
fix(security): 계정 잠금 DoS 제거 — "올바른 비밀번호는 항상 통과" (M-2)
mack-erel Jul 8, 2026
a6b641e
fix(security): admin 로그인에 계정 단위 잠금 추가 (M-8)
mack-erel Jul 8, 2026
5ee7be7
fix(security): 탈권한 시 OIDC refresh token 폐기 + refresh grant 접근권 재검증 (M-3)
mack-erel Jul 8, 2026
d837379
fix(security): MFA 백업코드를 무염 SHA-256 → scrypt(salted) 로 교체 (M-4)
mack-erel Jul 8, 2026
ce1819f
fix(security): 계정 self-service MFA step-up 스로틀 + replay 방어 (M-5)
mack-erel Jul 8, 2026
680c9a3
fix(security): skin sanitizer 인라인 style 오버레이 속성 무력화 (M-7)
mack-erel Jul 8, 2026
c9daee0
fix(security): api/users/lookup 레이트리밋 + 감사 로그 추가 (M-9)
mack-erel Jul 8, 2026
8005dd4
fix(security): IdP-initiated SAML SLO 응답에 InResponseTo 바인딩 검증 (M-6)
mack-erel Jul 8, 2026
df25c31
harden(security): admin 대시보드 role 재확인 + crud-factory tenantId spread …
mack-erel Jul 8, 2026
d32a3cf
harden(security): 로그아웃 CSRF(drive-by) 완화 — Sec-Fetch 가드 (LOW)
mack-erel Jul 8, 2026
ece5f37
harden(security): 비밀번호 최대 길이(128) 서버 강제 (LOW)
mack-erel Jul 8, 2026
6074970
harden(security): OIDC auth_time 정확도 + service-token 상수시간 비교 (LOW)
mack-erel Jul 8, 2026
651a569
harden(security): SAML forceAuthn 쿠키명에 쓰는 AuthnRequest ID 정규화 (LOW)
mack-erel Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "직급명을 입력해 주세요.",
Expand All @@ -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": "포트 번호가 올바르지 않습니다.",
Expand Down Expand Up @@ -745,6 +748,7 @@
},
"errors": {
"rate_limit": "요청이 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.",
"password_too_long": "비밀번호는 최대 {{max}}자까지 가능합니다.",
"db_not_ready": "D1 binding \"DB\" 가 준비되지 않았습니다. Wrangler preview/dev 환경에서 실행해 주세요."
},
"email": {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/server/admin/crud-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ export function createAdminCrudRoute<TCreateSchema extends z.ZodTypeAny, TUpdate
if (hookError) return fail(400, { create: true, error: hookError });
}

await db.insert(config.table).values({ tenantId: tenant.id, ...(values as Record<string, unknown>) } 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<string, unknown>), tenantId: tenant.id } as typeof config.table.$inferInsert);

const meta = getRequestMetadata(event);
await recordAuditEvent(db, {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/server/admin/user-actions/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]">;
Expand Down Expand Up @@ -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);
}

Expand Down
29 changes: 22 additions & 7 deletions src/lib/server/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,29 @@ export interface AuditEventInput {
detail?: Record<string, unknown>;
}

// 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,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/server/auth/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 11 additions & 7 deletions src/lib/server/auth/service-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const expected = config.dispatcherServiceToken;
if (!expected) {
throw error(503, "DISPATCHER_SERVICE_TOKEN 미설정 — service API 비활성");
Expand All @@ -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<boolean> {
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;
}
37 changes: 25 additions & 12 deletions src/lib/server/auth/totp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
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<boolean> {
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;
}
49 changes: 43 additions & 6 deletions src/lib/server/ldap/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ldap.Client> {
const client = createLdapClient(config);
if (config.tlsMode !== "starttls") return Promise.resolve(client);

// 일부 타입 정의에 starttls/destroy 가 노출되지 않아 명시 캐스팅.
const tlsClient = client as unknown as {
starttls: (options: Record<string, unknown>, controls: unknown[], callback: (err: Error | null) => void) => void;
destroy?: () => void;
};

return new Promise<ldap.Client>((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<void> {
if (!password) {
Expand All @@ -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);
});
Expand All @@ -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<string | null> {
const client = await connectLdapClient(config);
return new Promise((resolve, reject) => {
const client = createLdapClient(config);

client.on("error", (err: Error) => {
reject(err);
});
Expand Down Expand Up @@ -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<Record<string, string> | null> {
const client = await connectLdapClient(config);
return new Promise((resolve, reject) => {
const client = createLdapClient(config);

client.on("error", (err: Error) => {
reject(err);
});
Expand Down
25 changes: 25 additions & 0 deletions src/lib/server/oidc/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" },
Expand Down
Loading
Loading