diff --git a/packages/backend/src/controller/accessTokenDetector.ts b/packages/backend/src/controller/accessTokenDetector.ts new file mode 100644 index 0000000..8093a54 --- /dev/null +++ b/packages/backend/src/controller/accessTokenDetector.ts @@ -0,0 +1,170 @@ +import type { Request, Response } from "caido:utils"; +import type { SDK, DefineAPI } from "caido:plugin"; + +// 토큰 누출 검사 결과를 담는 구조 +export interface TokenLeakResult { + found: boolean; // 토큰이 발견되었는지 여부 (true/false) + location: 'url' | 'body' | 'header'; // 토큰이 발견된 위치 (url, body, header 중 하나) + title: string; // 경고 제목 + description: string; // 상세 설명 + value?: string; // 실제 발견된 값 (선택적) +} + +// 액세스 토큰 누출 검사 클래스 +export class AccessTokenLeakController { + async testReq(sdk: SDK>, request: Request): Promise { + const result = await this._scanRequest(request); + if (result) { + await sdk.findings.create({ + title: result.title, + description: result.description, + request, + reporter: "", + }); + } + } + + async testResp(sdk: SDK>, response: Response, request: Request): Promise { + const result = await this._scanResponse(response); + if (result) { + await sdk.findings.create({ + title: result.title, + description: result.description, + request, + reporter: "", + }); + } + } + + /** + * @param request - 검사할 HTTP 요청 객체 + * @returns 토큰이 발견되면 결과 객체, 없으면 null + */ + async _scanRequest(request: Request): Promise { + + // === 1. URL에서 토큰 검사 === + const url = request.getUrl(); + + const extractedTokenFromUrl = this.extractTokenFromText(url); + + if (extractedTokenFromUrl) { + return { + found: true, + location: 'url', + title: "Access Token Leak in URL", + description: `요청 URL에 토큰이 포함되어 있습니다. (토큰: ${extractedTokenFromUrl.substring(0, 20)}...)`, + value: url + }; + } + + // === 2. 요청 본문(Body)에서 토큰 검사 === + const body = request.getBody(); + + if (body) { + const bodyText = await body.toText(); + + const extractedTokenFromBody = this.extractTokenFromText(bodyText); + + if (extractedTokenFromBody) { + return { + found: true, + location: 'body', + title: "Access Token Leak in Request Body", + description: `요청 Body에 토큰이이 포함되어 있습니다. (토큰: ${extractedTokenFromBody.substring(0, 20)}...)`, + value: bodyText + }; + } + } + + return null; + } + + /** + * HTTP 응답에서 액세스 토큰 누출 검사 + * @param response - 검사할 HTTP 응답 객체 + * @returns 토큰이 발견되면 결과 객체, 없으면 null + */ + async _scanResponse(response: Response): Promise { + + // === 1. Location 헤더에서 토큰 검사 === + const locationHeader = response.getHeader("Location"); + + const locationHeaderStr = Array.isArray(locationHeader) ? locationHeader.join(', ') : locationHeader; + + if (locationHeaderStr) { + const extractedTokenFromHeader = this.extractTokenFromText(locationHeaderStr); + + if (extractedTokenFromHeader) { + return { + found: true, + location: 'header', + title: "Access Token Leak in Redirect URL", + description: `Location 헤더에 토큰이 노출되었습니다: ${locationHeaderStr} (토큰: ${extractedTokenFromHeader.substring(0, 20)}...)`, + value: locationHeaderStr + }; + } + } + + // === 2. 응답 본문에서 토큰 검사 === + const bodyBytes = response.getBody(); + + if (bodyBytes) { + const bodyText = await bodyBytes.toText(); + + const extractedTokenFromBody = this.extractTokenFromText(bodyText); + + if (extractedTokenFromBody) { + return { + found: true, + location: 'body', + title: "Access Token Leak in Response Body", + description: `HTTP 응답 본문에 토큰이 노출되었습니다. (토큰: ${extractedTokenFromBody.substring(0, 20)}...)`, + value: bodyText + }; + } + } + + return null; + } + + /** + * 텍스트에서 실제 토큰 값을 추출 + * @param text - 검사할 텍스트 + * @returns 토큰 값이 있으면 해당 값, 없으면 null + */ +private extractTokenFromText(text: string): string | null { + // 토큰 관련 키워드 리스트 + const tokenKeys = [ + 'access_token', + 'id_token', + 'auth_token', + 'token', + 'jwt', + 'session_token' + ]; + + // 정규표현식 패턴 리스트 생성 + const tokenPatterns: RegExp[] = []; + + for (const key of tokenKeys) { + // 1. key=token 또는 key: token + tokenPatterns.push(new RegExp(`${key}[=:]\\s*([a-zA-Z0-9\\-._~+/]+=*)`, 'i')); + + // 2. JSON 형태의 "key": "token" + tokenPatterns.push(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`, 'i')); + } + + // 3. Authorization: Bearer 형태 + tokenPatterns.push(/bearer\s+([a-zA-Z0-9\-._~+/]+=*)/i); + + // 모든 패턴에 대해 검사 + for (const pattern of tokenPatterns) { + const match = pattern.exec(text); + if (match && match[1]) { + return match[1]; + } + } + + return null; + } +} \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 49613d8..bab0ee0 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -4,6 +4,7 @@ import type { Request, Response } from "caido:utils"; // import { AuthZCodeGrantController } from "./controller/authZCodeGrant"; import { CsrfCheck } from "./controller/csrfCheck"; import { PKCECheck } from "./controller/PKCECheck"; +import { AccessTokenLeakController } from "./controller/accessTokenDetector"; import { ScopeDetection } from "./controller/scopeDetection"; export type API = DefineAPI<{}>; @@ -12,6 +13,7 @@ const csrfCheck = new CsrfCheck(); // const implicitGrantController = new ImplicitGrantController(); // const authZCodeGrantController = new AuthZCodeGrantController(); const pkceCheckController = new PKCECheck(); +const tokenCheck = new AccessTokenLeakController(); const ScopeDetectionController = new ScopeDetection(); export function init(sdk: SDK) { @@ -32,8 +34,9 @@ export function init(sdk: SDK) { async (sdk: SDK, {}>, req: Request, resp: Response) => { await csrfCheck.checker(sdk, req, resp); await pkceCheckController.test(sdk, req); + await tokenCheck.testReq(sdk, req); + await tokenCheck.testResp(sdk, resp, req); await ScopeDetectionController.scan(sdk, req.getUrl()); - // sdk.events.onInterceptRequest(async (sdk, req: Request) => { // const result = // authZCodeGrantController.testReq(req) ||