diff --git a/dist/plugin_package.zip b/dist/plugin_package.zip new file mode 100644 index 0000000..a321b3b Binary files /dev/null and b/dist/plugin_package.zip differ 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; + } +}