From e868cbec676528332494086f58e7cfbbaeb0e26e Mon Sep 17 00:00:00 2001 From: "tv0924@icloud.com" Date: Wed, 28 May 2025 14:11:53 +0900 Subject: [PATCH] =?UTF-8?q?csrf(state)=20=EA=B4=80=EB=A0=A8=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90=20=ED=83=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/plugin_package.zip | Bin 0 -> 11096 bytes package.json | 2 +- packages/backend/src/controller/csrfCheck.ts | 178 +++++++++++++++++ packages/backend/src/index.ts | 34 +++- packages/backend/src/utils/http.ts | 193 +++++++++++++++++++ 5 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 dist/plugin_package.zip create mode 100644 packages/backend/src/controller/csrfCheck.ts create mode 100644 packages/backend/src/utils/http.ts diff --git a/dist/plugin_package.zip b/dist/plugin_package.zip new file mode 100644 index 0000000000000000000000000000000000000000..a321b3b7a1ce78282a403d57a664b8d3f0a1ed8b GIT binary patch literal 11096 zcmWIWW@h1H0D;#Ud!j%z40A9rFeD`=XQ$?+=tES2M9@_UAgRjCOG&NJ%PQ8_S13qK z&Q45EE!KybP+XL(Us{rxQ>>p+Qc|E-Qp{DBSfr4dS6q^qmz=6#tB_ZklVc4Q^e8DQ z2n8usuvJLTNh~f_sOC~o(AU!9QczIPQh3w7@zsuow=G)~-pt+crfbRDmMw3(mb_lN zN8wHL+&3*7-%efmy1V0b_Yys@dVMYhh2;Fa;*z4$0>)t5}uQtqmvuVqlxib{r zHZ6HGvq9n2hDmScwkW(=(DHg)gTkA(p4V%3yqP!Q^};y{uQxZmojC)|7YYsqiA9OI z3K^-1DXB%p3JSUkL8;04MJYDLB}JKe={gD!TA>EYh>o>Wcspb2+vYi7TOrPPy?iUk zD_RN;5O)_VK!GlbyS6C2UbW)�l8k^?Je_g*VL|3Q&*1Oaz5;PJVf6k#k~ks$))$ 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 7d2ba1b..ccb27ef 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "caido-oauth", + "name": "caido-oauth-dev", "version": "0.0.0", "private": true, "scripts": { diff --git a/packages/backend/src/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts new file mode 100644 index 0000000..371033f --- /dev/null +++ b/packages/backend/src/controller/csrfCheck.ts @@ -0,0 +1,178 @@ +import type { Request, Response } from "caido:utils"; +import type { SDK, DefineAPI } from "caido:plugin"; +import { HttpUtils } from "../utils/http"; + +const httpUtils = new HttpUtils(); + +export class CsrfCheck { + private isOauthUri(request: Request): boolean { + const query = request.getQuery() || ""; + + // Check if the request is an OAuth authorization request + if ( + query.includes("client_id=") && + (query.includes("response_type=") || + query.includes("grant_type=") || + query.includes("redirect_uri=") || + query.includes("scope=") || + query.includes("state=")) + ) { + return true; + } + + return false; + } + + private isOauthRedirectResponse(response: Response): boolean { + const status = response.getCode(); + const locationHeader = httpUtils.getHeaderValue( + response.getHeaders(), + "location" + ); + + if ( + status >= 300 && + status < 400 && + locationHeader && + (locationHeader.includes("client_id=") || + locationHeader.includes("response_type=") || + locationHeader.includes("grant_type=") || + locationHeader.includes("redirect_uri=") || + locationHeader.includes("scope=") || + locationHeader.includes("state=") || + locationHeader.includes("code=")) // code is also common in OAuth redirects + ) { + return true; + } + return false; + } + + private isStateInQuery(request: Request): boolean { + const query = request.getQuery(); + const stateValue = httpUtils.getQueryParam(query || "", "state"); + if (!stateValue) { + return false; + } + return true; + } + + private checkStateAtResponseLocationHeader( + request: Request, + response: Response + ): string[] | 0 { + if ( + !( + this.isOauthUri(request) && + this.isStateInQuery(request) && + this.isOauthRedirectResponse(response) + ) + ) { + return 0; // Not a target, no CSRF risk + } + + // 요청에서 보낸 state 추출 + const query = request.getQuery() || ""; + const originalState = httpUtils.getQueryParam(query, "state"); + + // 리다이렉트 URL에서 쿼리 부분만 추출 + const locationHeader = httpUtils.getHeaderValue( + response.getHeaders(), + "location" + ); + const responseState = httpUtils.getQueryParamFromURI( + locationHeader || "", + "state" + ); + + // state가 없거나, 요청값과 다르면 CSRF 위험 + if (!responseState) { + // missing state + return ["state parameter is missing in the response location header"]; + } + if (originalState !== responseState) { + // mismatch + return ["state parameter mismatch between request and response"]; + } + + return 0; // no CSRF risk detected + } + + // private async checkStateReuse( + // request: Request, + // originResponse: Response + // ): Promise { + // // uri에 oauth 관련 파라미터가 없지만, 응답이 oauth 리다이렉트 응답인지 확인 + // // 즉, 처음으로 state를 발급한 요청인지 확인 + // if ( + // !( + // !this.isOauthUri(request) && + // this.isOauthRedirectResponse(originResponse) + // ) + // ) { + // return 0; // Not a target, no CSRF risk + // } + + // const originResponseLocationHeader = httpUtils.getHeaderValue( + // originResponse.getHeaders(), + // "location" + // ); + // const originState = httpUtils.getQueryParamFromURI( + // originResponseLocationHeader || "", + // "state" + // ); + + // const requestHeaders = request.getHeaders(); + // const noCookieHeaders = httpUtils.removeHeaders(requestHeaders, ["cookie"]); + // const newResponse = await httpUtils.resend(request, { + // headers: noCookieHeaders, + // }); + // const newLocationHeader = httpUtils.getHeaderValue( + // newResponse.getHeaders(), + // "location" + // ); + // const newState = httpUtils.getQueryParamFromURI( + // newLocationHeader || "", + // "state" + // ); + + // if (originState === newState) { + // return [ + // "State parameter reused in the response location header, indicating a potential CSRF risk", + // ]; + // } + + // return 0; // no CSRF risk detected + // } + + async checker( + sdk: SDK, {}>, + request: Request, + response: Response + ): Promise { + let result = ``; + + // 쿼리에 state 파라미터가 없으면 CSRF 위험 + if (this.isOauthUri(request) && !this.isStateInQuery(request)) { + result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter + } + + // location 헤더에 state 파라미터가 없거나, 요청에서 보낸 state와 다르면 CSRF 위험 + const stateAtResponseLocationHeaderCheck = + this.checkStateAtResponseLocationHeader(request, response); + if (stateAtResponseLocationHeaderCheck !== 0) { + result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`; + } + + // // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 + // const reusedStateCheck = await this.checkStateReuse(request, response); + // if (reusedStateCheck !== 0) { + // result += `, ${reusedStateCheck.join(", ")}`; + // } + + if (result) { + return result; // CSRF risk detected + } else { + return 0; // No CSRF risk detected + } + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7633932..8a8ca26 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,5 +1,6 @@ import type { SDK, DefineAPI } from "caido:plugin"; import type { Request } from "caido:utils"; +<<<<<<< HEAD import { ImplicitGrantController } from "./controller/implictGrant"; import { AuthZCodeGrantController } from "./controller/authZCodeGrant"; import { PKCECheck } from "./controller/PKCECheck"; @@ -27,19 +28,40 @@ const pkceCheck = new PKCECheck(); // const match = /"access_token"\s*:\s*"([^"]+)"/.exec(raw); // return !!match; // } +======= +// import { ImplicitGrantController } from "./controller/implictGrant"; +// import { AuthZCodeGrantController } from "./controller/authZCodeGrant"; +import { CsrfCheck } from "./controller/csrfCheck"; + +export type API = DefineAPI<{}>; +const csrfCheck = new CsrfCheck(); +>>>>>>> 8de17eb (csrf(state) 관련 취약점 탐지 기능 추가) export function init(sdk: SDK) { - sdk.events.onInterceptRequest(async (sdk, req: Request) => { - const result = - authZCodeGrantController.testReq(req) || - implicitGrantController.testReq(req); + // sdk.events.onInterceptRequest(async (sdk, req: Request) => { + // const result = csrfCheck.checker(req); + + // if (result) { + // await sdk.findings.create({ + // title: "Possible SSO Request Detected", + // description: `SSO-related parameters detected in request:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`, + // request: req, + // reporter: "", + // }); + // } + // }); + + sdk.events.onInterceptResponse(async (sdk, req: Request, resp) => { + const funcList = [csrfCheck.checker(sdk, req, resp)]; + + let result = await Promise.all(funcList); if (result) { await pkceCheck.test(sdk, req); await sdk.findings.create({ - title: "Possible SSO Request Detected", - description: `SSO-related parameters detected in request:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`, + title: "Possible SSO Response Detected", + description: `SSO-related parameters detected in response:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`, request: req, reporter: "", }); diff --git a/packages/backend/src/utils/http.ts b/packages/backend/src/utils/http.ts new file mode 100644 index 0000000..91a6527 --- /dev/null +++ b/packages/backend/src/utils/http.ts @@ -0,0 +1,193 @@ +let instance: HttpUtils | null = null; +export class HttpUtils { + /** + * 싱글턴 인스턴스를 생성합니다. + */ + public constructor() { + if (instance) { + return instance; + } + instance = this; + return instance; + } + + /** + * 헤더 객체의 키와 값을 전부 소문자로 변환합니다. + * @param headers - Record 형태의 헤더 맵 + * @returns - 키와 값이 모두 소문자로 변환된 새 헤더 맵 + */ + lowerCaseAllHeaders( + headers: Record + ): Record { + const result: Record = {}; + + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.toLowerCase(); + + if (Array.isArray(rawValue)) { + // 배열이면 각 요소를 소문자로 + result[key] = rawValue.map((v) => v.toLowerCase()); + } else { + // 단일 문자열이면 바로 소문자로 + result[key] = rawValue.toLowerCase(); + } + } + + return result; + } + + getQueryParamFromURI(uri: string, key: string): string | null { + uri = uri.toLowerCase(); + key = key.toLowerCase(); + try { + const urlObj = new URL(uri); + return urlObj.searchParams.get(key); + } catch (e) { + return null; + } + } + + // Query + /** + * 주어진 쿼리 문자열(query)에서 key에 해당하는 값을 반환합니다. + * @param query - "a=1&b=2..." 형태의 쿼리 문자열 (맨 앞 ? 는 없어야 합니다) + * @param key - 가져오고 싶은 파라미터 이름 + * @returns - 해당 파라미터 값, 없으면 null + */ + getQueryParam(query: string, key: string): string | null { + query = query.toLowerCase(); + key = key.toLowerCase(); + + const params = new URLSearchParams(query); + return params.get(key); + } + + /** + * 주어진 쿼리 문자열(query)에 key=value를 설정하고, 전체 쿼리 문자열을 반환합니다. + * - 이미 key가 있으면 덮어쓰기(set), 없으면 새로 추가합니다. + * @param query - "a=1&b=2..." 형태의 쿼리 문자열 (맨 앞 ? 는 없어야 합니다) + * @param key - 설정할 파라미터 이름 + * @param value - 설정할 값 + * @returns - "a=1&b=2&c=3..." 형태의 새로운 쿼리 문자열 + */ + setQueryParam(query: string, key: string, value: string): string { + query = query.toLowerCase(); + key = key.toLowerCase(); + value = value.toLowerCase(); + + const params = new URLSearchParams(query); + params.set(key, value); + return params.toString(); + } + + /** + * 주어진 쿼리 문자열(query)에서 key에 해당하는 파라미터를 삭제(delete)하고, + * 전체 쿼리 문자열을 반환합니다. + * @param query - "a=1&b=2..." 형태의 쿼리 문자열 (맨 앞 ? 는 없어야 합니다) + * @param key - 삭제할 파라미터 이름 + * @returns - 삭제된 상태의 새로운 쿼리 문자열 + */ + removeQueryParam(query: string, key: string): string { + query = query.toLowerCase(); + key = key.toLowerCase(); + + const params = new URLSearchParams(query); + params.delete(key); + return params.toString(); + } + + // Headers + /** + * 주어진 헤더 맵에서 name에 해당하는 첫 번째 헤더 값을 반환합니다. + * @param headers - Response.getHeaders() 가 반환하는 객체 + * @param name - 꺼내고 싶은 헤더 이름 (예: "location", "Content-Type") + * @returns - 해당 헤더의 첫 번째 값, 없으면 null + */ + getHeaderValue( + headers: Record, + name: string + ): string | null { + headers = this.lowerCaseAllHeaders(headers); + const target = name.toLowerCase(); + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + if (Array.isArray(value)) { + // 배열 형태일 때 첫 번째 요소가 비어있을 수도 있으니 안전하게 처리 + return value.length > 0 && + value[0] !== undefined && + value[0].length > 0 + ? value[0] + : null; + } + // 문자열일 때 + return value.length > 0 ? value : null; + } + } + return null; + } + + /** + * 주어진 헤더 맵에서 name에 해당하는 헤더 값을 value로 변경한 새 맵을 반환합니다. + * - 기존 헤더 이름의 대소문자를 보존합니다. + * - value가 string인 경우 [value] 형태로, string[]인 경우 그대로 사용합니다. + * - 기존에 해당 헤더가 없으면 새로 추가합니다. + * + * @param headers - 키가 헤더 이름, 값이 문자열 배열인 헤더 맵 + * @param name - 변경할 헤더 이름 (예: "Authorization", "X-Custom-Header") + * @param value - 새로 설정할 값 (string 또는 string[]) + * @returns - 지정된 헤더가 업데이트된 새로운 헤더 맵 + */ + setHeaderValue( + headers: Record, + name: string, + value: string | string[] + ): Record { + headers = this.lowerCaseAllHeaders(headers); + const lowerName = name.toLowerCase(); + const newHeaders: Record = {}; + + // 1) 기존 헤더 복사하되, name과 일치하는 항목은 value로 대체 + for (const [key, vals] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) { + newHeaders[key] = Array.isArray(value) ? value : [value]; + } else { + newHeaders[key] = Array.isArray(vals) ? vals : [vals]; + } + } + + // 2) 해당 헤더가 원래 없었다면 새로 추가 + const exists = Object.keys(newHeaders).some( + (k) => k.toLowerCase() === lowerName + ); + if (!exists) { + newHeaders[name] = Array.isArray(value) ? value : [value]; + } + + return newHeaders; + } + + /** + * 주어진 헤더 맵에서 특정 이름(들)에 해당하는 헤더를 제거한 새 맵을 반환합니다. + * @param headers - 키가 헤더 이름, 값이 문자열 배열인 헤더 맵 + * @param namesToRemove - 제거할 헤더 이름(하나 혹은 배열). 대소문자 구분 없이 매칭됩니다. + * @returns - 지정된 헤더가 제외된 새로운 헤더 맵 + */ + removeHeaders( + headers: Record, + namesToRemove: string | string[] + ): Record { + headers = this.lowerCaseAllHeaders(headers); + const toRemove = Array.isArray(namesToRemove) + ? namesToRemove.map((n) => n.toLowerCase()) + : [namesToRemove.toLowerCase()]; + + const filtered: Record = {}; + for (const [key, vals] of Object.entries(headers)) { + if (!toRemove.includes(key.toLowerCase())) { + filtered[key] = Array.isArray(vals) ? vals : [vals]; + } + } + return filtered; + } +}