Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ declare global {
// Cloudflare Hyperdrive 바인딩 (wrangler 의 hyperdrive 설정으로 주입).
// DB: DB_DIALECT=d1 일 때만 존재하는 Cloudflare D1 바인딩. D1 은 선택적 방언이므로
// 모든 wrangler.jsonc 가 선언하지는 않는다 — optional 로 두어 non-d1 배포에서도 타입이 성립한다.
env: Env & { SKIN_CACHE?: R2Bucket; HYPERDRIVE?: { connectionString: string }; DB?: D1Database };
// EMAIL: Cloudflare Email Sending 바인딩(send_email). 설정 시 Workers 에서
// nodemailer 대신 이 바인딩으로 트랜잭션 메일을 발송한다. wrangler.jsonc 의
// send_email 로 주입되며, 없는 배포도 있으므로 optional.
env: Env & { SKIN_CACHE?: R2Bucket; HYPERDRIVE?: { connectionString: string }; DB?: D1Database; EMAIL?: SendEmail };
ctx: ExecutionContext;
caches: CacheStorage;
cf?: IncomingRequestCfProperties;
Expand Down
82 changes: 74 additions & 8 deletions src/lib/server/email.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
import nodemailer from "nodemailer";
import { env } from "$env/dynamic/private";

// ── 발송 코어: 런타임 분기 ────────────────────────────────────────────────────
// B6: nodemailer(raw TCP SMTP)는 Cloudflare Workers 런타임에서 동작하지 않는다
// (net/tls 미지원). 따라서 발송 경로를 런타임별로 분기한다.
// - Cloudflare Workers: send_email 바인딩(platform.env.EMAIL)이 있으면
// Cloudflare Email Sending 으로 발송한다. 이 바인딩은 sender 도메인만
// 온보딩되어 있으면 "임의 외부 수신자"에게 트랜잭션 메일을 보낼 수 있다
// (비밀번호 재설정/아이디 찾기에 적합). 수신자 검증이 필요한 것은 구
// Email Routing 의 forward 이지, Email Sending 발송 경로가 아니다.
// - 그 외(Node/adapter-node): 기존 nodemailer(SMTP)로 발송한다.
// nodemailer 는 top-level import 를 제거하고 **동적 import** 로 바꿔
// Workers 번들/런타임에 net/tls 의존이 끌려들어가지 않게 한다.

type EnvLookup = Record<string, unknown>;

// send_email 바인딩(SendEmail)은 workerd 런타임이 주입하며 worker-configuration.d.ts
// 에 전역 타입으로 존재한다. platform.env 는 Env 확장이라 아직 EMAIL 이 선언돼
// 있지 않을 수 있으므로 방어적으로 조회한다.
function getEmailBinding(platform: App.Platform | undefined): SendEmail | undefined {
const binding = (platform?.env as EnvLookup | undefined)?.EMAIL as SendEmail | undefined;
// send() 메서드가 존재해야 유효한 바인딩으로 간주.
return binding && typeof binding.send === "function" ? binding : undefined;
}

function readEnv(platform: App.Platform | undefined, key: string): string | undefined {
const fromPlatform = (platform?.env as EnvLookup | undefined)?.[key];
if (typeof fromPlatform === "string" && fromPlatform.length > 0) return fromPlatform;
// $env/dynamic/private 는 Workers 에서 platform.env, Node 에서 process.env 를 반영한다.
const fromEnv = (env as EnvLookup)?.[key];
return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : undefined;
}

// Cloudflare Email 발송에 필요한 sender 정보. from 주소는 반드시 Email Sending 에
// 온보딩된 도메인이어야 한다(wrangler email sending enable <domain>). 미설정이면
// null 을 돌려 상위에서 "설정 없음"으로 처리한다.
function getCloudflareFrom(platform: App.Platform | undefined): { email: string; name: string } | null {
const email = readEnv(platform, "EMAIL_FROM");
if (!email) return null;
const name = readEnv(platform, "EMAIL_FROM_NAME") ?? "KeyStone";
return { email, name };
}

function getSmtpConfig() {
const hostname = env.SMTP_HOSTNAME;
const port = env.SMTP_PORTNUMB;
Expand All @@ -18,10 +58,12 @@ function getSmtpConfig() {
};
}

async function send(to: string, subject: string, html: string): Promise<void> {
async function sendViaNodemailer(to: string, subject: string, html: string, text: string): Promise<void> {
const smtp = getSmtpConfig();
if (!smtp) throw new Error("SMTP 설정이 없습니다.");
if (!smtp) throw new Error("이메일 발송 설정이 없습니다. (SMTP_* 미설정)");

// 동적 import: Workers 번들에 net/tls 의존을 top-level 로 끌어오지 않도록.
const { default: nodemailer } = await import("nodemailer");
const transporter = nodemailer.createTransport({
host: smtp.hostname,
port: smtp.port,
Expand All @@ -31,12 +73,34 @@ async function send(to: string, subject: string, html: string): Promise<void> {
transporter.setMaxListeners(20);

try {
await transporter.sendMail({ from: smtp.senderAddress, to, subject, html });
await transporter.sendMail({ from: smtp.senderAddress, to, subject, html, text });
} finally {
transporter.close();
}
}

// text 파트는 각 발송 함수가 명시적으로 작성해 넘긴다(HTML 을 정규식으로 역파싱하지 않는다 —
// 불완전 sanitization/이중 언이스케이프를 피하고, text/plain 렌더링 정확도를 높인다).
async function send(to: string, subject: string, html: string, text: string, platform: App.Platform | undefined): Promise<void> {
// 1) Cloudflare Workers 경로 — send_email 바인딩이 있으면 최우선.
const emailBinding = getEmailBinding(platform);
if (emailBinding) {
const from = getCloudflareFrom(platform);
if (!from) throw new Error("이메일 발송 설정이 없습니다. (EMAIL_FROM 미설정 — Email Sending 온보딩 도메인 주소 필요)");
await emailBinding.send({
to,
from: { email: from.email, name: from.name },
subject,
html,
text,
});
return;
}

// 2) Node(adapter-node) 경로 — nodemailer(SMTP). 동적 import.
await sendViaNodemailer(to, subject, html, text);
}

// ctrls H-MAIL-1: 이메일 본문 템플릿에 들어가는 동적 값들은 HTML/attribute
// 컨텍스트 escape 가 필요하다. 정책 변경으로 username 정규식이 완화되거나
// resetUrl 의 형식 검증을 우회한 입력이 들어와도 HTML 인젝션이 발생하지 않도록
Expand Down Expand Up @@ -68,16 +132,17 @@ function baseHtml(title: string, body: string): string {
</html>`;
}

export async function sendFindIdEmail(to: string, username: string): Promise<void> {
export async function sendFindIdEmail(to: string, username: string, platform?: App.Platform): Promise<void> {
const html = baseHtml(
"아이디 확인",
`<p>요청하신 아이디 정보입니다.</p>
<p style="font-size:20px;font-weight:700;margin:24px 0;">${escapeHtml(username)}</p>`,
);
await send(to, "아이디 안내", html);
const text = `요청하신 아이디 정보입니다.\n\n${username}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`;
await send(to, "아이디 안내", html, text, platform);
}

export async function sendPasswordResetEmail(to: string, resetUrl: string): Promise<void> {
export async function sendPasswordResetEmail(to: string, resetUrl: string, platform?: App.Platform): Promise<void> {
const safeUrl = safeAbsoluteUrl(resetUrl);
if (!safeUrl) {
// 잘못된 URL 형식이면 메일 발송 자체 거부 — silent skip 으로 user enumeration 차단.
Expand All @@ -91,7 +156,8 @@ export async function sendPasswordResetEmail(to: string, resetUrl: string): Prom
<a href="${escapeHtml(safeUrl)}" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">비밀번호 재설정</a>
</p>`,
);
await send(to, "비밀번호 재설정 안내", html);
const text = `아래 링크에서 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다.\n\n${safeUrl}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`;
await send(to, "비밀번호 재설정 안내", html, text, platform);
}

export async function generateToken(): Promise<{ token: string; tokenHash: string }> {
Expand Down
120 changes: 120 additions & 0 deletions src/lib/server/saml/verify-xml-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Enveloped XML 서명(ds:Signature) 검증 — HTTP-POST 바인딩 AuthnRequest 용.
*
* HTTP-Redirect 바인딩은 서명이 URL 쿼리에 detached 로 실리지만(verifySamlRedirectSignature),
* HTTP-POST 바인딩의 서명 AuthnRequest 는 요청 XML 내부에 enveloped ds:Signature 로 실린다.
* 이 모듈은 xmldsigjs 로 그 서명을 검증한다.
*
* 신뢰 모델 (매우 중요):
* - 검증 키는 **오직 우리가 등록·신뢰하는 SP 인증서(certPem)의 공개키**만 사용한다.
* - XML 내부 <ds:KeyInfo> 의 인증서는 절대 신뢰하지 않는다. 공격자가 자기 키로 서명하고
* 자기 인증서를 KeyInfo 에 심으면, KeyInfo 를 신뢰할 경우 위조가 통과되기 때문이다.
* - xmldsigjs 의 SignedXml.Verify(publicKey) 는 인자로 키를 넘기면 KeyInfo(GetPublicKeys)를
* 쓰지 않고 그 키로만 검증한다(signed_xml.js: `const keys = key ? [key] : GetPublicKeys()`).
*
* 서명 래핑(XML Signature Wrapping, XSW) 방어:
* 1. 문서 내 <ds:Signature> 는 정확히 1개여야 하며, 문서 루트(AuthnRequest)의 직계 자식이어야 한다
* (enveloped 서명이 루트를 감싼다).
* 2. SignedInfo 의 Reference 는 정확히 1개여야 하며, 그 URI 는 문서 루트의 실제 ID(`#<rootID>`)를
* 가리켜야 한다. 다른 요소(주입된 wrapper)를 가리키는 서명은 거부한다.
* 3. 그 Reference 에 enveloped-signature transform 이 포함되어야 한다.
* 4. xmldsigjs 내부의 중복 ID 감지(findAllByIdExcludingSignatures)가 동일 ID 를 가진 두 요소를
* 발견하면 예외를 던진다 — wrapper 로 동일 ID 를 복제하는 공격을 차단한다.
* 5. 서명/다이제스트 알고리즘은 SHA-256 이상만 허용한다(레거시 SHA-1 은 명시 옵트인 시만).
*
* ⚠ 이 검증은 우리(IdP)가 자체적으로 수행할 수 있는 암호 검증까지만 보장한다. 실제 SP 상호운용
* (SP 가 실제로 어떤 c14n/transform/서명 배치를 쓰는지)은 별도의 interop 테스트가 필요하다.
*/

import "reflect-metadata";
import { X509Certificate } from "@peculiar/x509";
import { env } from "$env/dynamic/private";
import { ensureXmlEngine, xmldsigjs } from "./xml-setup";

const XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
const ENVELOPED_TRANSFORM = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";

// 허용 SignatureMethod (SHA-256 이상). SHA-1 은 IDP_ALLOW_SAML_SHA1=true 일 때만 예외 허용.
const ALLOWED_SIG_METHODS = new Set(["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"]);
const SHA1_SIG_METHOD = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";

// 허용 DigestMethod (SHA-256 이상). SHA-1 은 동일하게 옵트인 시만.
const ALLOWED_DIGEST_METHODS = new Set(["http://www.w3.org/2001/04/xmlenc#sha256", "http://www.w3.org/2001/04/xmldsig-more#sha384", "http://www.w3.org/2001/04/xmlenc#sha512"]);
const SHA1_DIGEST_METHOD = "http://www.w3.org/2000/09/xmldsig#sha1";

function sha1Allowed(): boolean {
return env.IDP_ALLOW_SAML_SHA1 === "true";
}

/**
* AuthnRequest XML 의 enveloped ds:Signature 를 SP 인증서 공개키로 검증한다.
* 검증 불가·형식 위반·서명 불일치 시 예외 없이 false 를 반환한다 (호출부는 false → 거부).
*
* @param xml AuthnRequest 원본 XML 문자열 (base64 디코드 후, deflate 없음)
* @param certPem 신뢰하는 SP 인증서(PEM). 이 인증서의 공개키로만 검증한다.
*/
export async function verifyEnvelopedXmlSignature(xml: string, certPem: string): Promise<boolean> {
try {
if (!certPem) return false;
ensureXmlEngine();

// DOCTYPE/ENTITY 방어(호출 전 파서에서 이미 차단되지만 이중 방어).
if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) return false;

const doc = xmldsigjs.Parse(xml);
const root = doc.documentElement;
if (!root) return false;

// 서명 대상 식별: 루트의 ID 속성. 없으면 무엇이 서명됐는지 확정할 수 없어 거부.
const rootId = root.getAttribute("ID");
if (!rootId) return false;

// XSW 방어 (1): Signature 는 정확히 1개, 루트의 직계 자식이어야 한다.
const sigEls = doc.getElementsByTagNameNS(XMLDSIG_NS, "Signature");
if (sigEls.length !== 1) return false;
const sigEl = sigEls[0];
if (!sigEl || sigEl.parentNode !== root) return false;

// 신뢰하는 SP 인증서의 공개키. extractable=true — xmldsigjs Verify 내부에서
// SignatureMethod 에 맞춰 키를 재-import 하려고 SPKI 를 export 하기 때문.
const cert = new X509Certificate(certPem);
const publicKey = await crypto.subtle.importKey("spki", cert.publicKey.rawData, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"]);

const signedXml = new xmldsigjs.SignedXml(doc);
signedXml.LoadXml(sigEl);

const signedInfo = signedXml.XmlSignature.SignedInfo;

// 알고리즘 화이트리스트 (SignatureMethod).
const sigMethod = signedInfo.SignatureMethod?.Algorithm ?? "";
if (!ALLOWED_SIG_METHODS.has(sigMethod) && !(sigMethod === SHA1_SIG_METHOD && sha1Allowed())) {
return false;
}

// XSW 방어 (2): Reference 는 정확히 1개, URI 는 문서 루트 ID 를 가리켜야 한다.
const refs = signedInfo.References;
if (!refs || refs.Count !== 1) return false;
const ref = refs.Item(0);
if (!ref) return false;
if (ref.Uri !== `#${rootId}`) return false;

// 알고리즘 화이트리스트 (DigestMethod).
const digestMethod = ref.DigestMethod?.Algorithm ?? "";
if (!ALLOWED_DIGEST_METHODS.has(digestMethod) && !(digestMethod === SHA1_DIGEST_METHOD && sha1Allowed())) {
return false;
}

// XSW 방어 (3): enveloped-signature transform 이 반드시 포함되어야 한다.
const transforms = ref.Transforms;
const hasEnveloped = transforms ? transforms.Some((t) => t.Algorithm === ENVELOPED_TRANSFORM) : false;
if (!hasEnveloped) return false;

// 서명 검증 — 반드시 신뢰하는 SP 공개키로만. 인자로 키를 넘기면 xmldsigjs 는
// KeyInfo 를 무시하고 이 키로만 검증한다. 다이제스트 검증(ValidateReferences)에서
// 중복 ID(XSW wrapper 복제)도 예외로 걸러진다.
return await signedXml.Verify(publicKey);
} catch {
// 파싱 실패·중복 ID 예외·다이제스트 불일치 등 모든 오류는 "검증 실패" = 거부.
return false;
}
}
2 changes: 1 addition & 1 deletion src/routes/(auth)/find-id/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const actions: Actions = {
// 왕복을 돌면 응답 시간 차이로 존재 여부가 새어 나간다. 메일 발송을 응답
// 경로에서 분리해 (Workers: waitUntil, Node: fire-and-forget) 존재/비존재
// 응답 시간을 균일하게 맞춘다. 메일 발송은 best-effort 이므로 결과를 기다리지 않는다.
const sendPromise = sendFindIdEmail(email, user.username).catch(() => {
const sendPromise = sendFindIdEmail(email, user.username, event.platform).catch(() => {
// 메일 발송 실패는 조용히 무시
});
const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx);
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(auth)/find-password/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const actions: Actions = {
const resetUrl = `${issuer}/reset-password?${resetParams.toString()}`;
// ctrls C5(후속): SMTP 왕복을 응답 경로에서 분리해 타이밍 계정 열거를 차단한다.
// (find-id 와 동일 패턴 — Workers: waitUntil, Node: fire-and-forget.)
const sendPromise = sendPasswordResetEmail(user.email, resetUrl).catch(() => {
const sendPromise = sendPasswordResetEmail(user.email, resetUrl, event.platform).catch(() => {
// 메일 발송 실패는 조용히 무시
});
const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx);
Expand Down
Loading
Loading