diff --git a/packages/backend/src/controller/accessTokenDetector.ts b/packages/backend/src/controller/accessTokenDetector.ts index c0570d0..6e95120 100644 --- a/packages/backend/src/controller/accessTokenDetector.ts +++ b/packages/backend/src/controller/accessTokenDetector.ts @@ -19,7 +19,7 @@ export class AccessTokenLeakController { title: result.title, description: result.description, request, - reporter: "AccessTokenLeak", + reporter: "", }); } } @@ -31,7 +31,7 @@ export class AccessTokenLeakController { title: result.title, description: result.description, request, - reporter: "AccessTokenLeak", + reporter: "", }); } } @@ -132,53 +132,34 @@ export class AccessTokenLeakController { * @param text - 검사할 텍스트 * @returns 토큰 값이 있으면 해당 값, 없으면 null */ - private extractTokenFromText(text: string): string | null { +private extractTokenFromText(text: string): string | null { // 토큰 관련 키워드 리스트 const tokenKeys = [ - 'access_token', - 'accesstoken', - 'Access-Token', - 'Refresh_Token', - 'Refresh-Token', - 'RefreshToken', - 'Secret_Token', - 'Secret-Token', - 'SecretToken', - 'SSO_Auth', - 'SSO-Auth', - 'SSOAuth', - 'auth_token', - 'session_token' - ]; + 'access_token', + 'accesstoken', + 'Access-Token', + 'Refresh_Token', + 'Refresh-Token', + 'RefreshToken', + 'Secret_Token', + 'Secret-Token', + 'SecretToken', + 'SSO_Auth', + 'SSO-Auth', + 'SSOAuth', + 'auth_token', + 'session_token' + ]; - const tokenTypeKeys = [ - 'token_type', - 'tokenType' - ]; - - // 정규표현식 토큰 타입 유무 패턴 리스트 생성 - const tokenTypeRegexes: RegExp[] = []; - for (const key of tokenTypeKeys) { - // JSON 형식: "token_type": "Bearer" - tokenTypeRegexes.push(new RegExp(`"${key}"\\s*:\\s*"bearer"`, 'i')); - // 일반 key=value 형식: token_type=Bearer - tokenTypeRegexes.push(new RegExp(`${key}[=:]\\s*bearer`, 'i')); - // 공백 있는 형식: token_type : Bearer - tokenTypeRegexes.push(new RegExp(`${key}\\s*:\\s*bearer`, 'i')); - } - - // token_type=bearer 형태 중 하나라도 포함되는지 확인 - const hasTokenTypeBearer = tokenTypeRegexes.some(rx => rx.test(text)); - - // 정규표현식 토큰 유무 패턴 리스트 생성 + // 정규표현식 패턴 리스트 생성 const tokenPatterns: RegExp[] = []; for (const key of tokenKeys) { - // 1. key=token 또는 key: token - tokenPatterns.push(new RegExp(`${key}[=:]\\s*([a-zA-Z0-9\\-._~+/]+=*)`, 'i')); + // 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')); + // 2. JSON 형태의 "key": "token" + tokenPatterns.push(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`, 'i')); } // 3. Authorization: Bearer 형태 @@ -186,14 +167,12 @@ export class AccessTokenLeakController { // 모든 패턴에 대해 검사 for (const pattern of tokenPatterns) { - const match = pattern.exec(text); - if (match && match[1]) { - if(hasTokenTypeBearer){ - return match[1]; + 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/controller/csrfCheck.ts b/packages/backend/src/controller/csrfCheck.ts index 8a6f723..5931428 100644 --- a/packages/backend/src/controller/csrfCheck.ts +++ b/packages/backend/src/controller/csrfCheck.ts @@ -5,15 +5,6 @@ import { HttpUtils } from "../utils/http"; const httpUtils = new HttpUtils(); export class CsrfCheck { - private nonceParam = [ - "state", - "nonce", - "as", - "frame_id", - "csrf_token", - "csrf", - ]; - private isTargetUri(uri: string): boolean { if ( httpUtils.getQueryParamFromURI(uri, "client_id") !== null && @@ -52,179 +43,106 @@ export class CsrfCheck { return false; } - private isNonceInQuery(request: Request): boolean { - const query = request.getQuery() || ""; - - for (const param of this.nonceParam) { - if (httpUtils.getQueryParam(query, param) !== null) { - return true; // Nonce parameter is present in the query - } + private isStateInQuery(request: Request): boolean { + const query = request.getQuery(); + const stateValue = + httpUtils.getQueryParam(query || "", "state") || + httpUtils.getQueryParam(query || "", "nonce"); + if (!stateValue) { + return false; } - - return false; // No nonce parameter found in the query + return true; } - private getNonceParamName(url: string): string | null { - for (const param of this.nonceParam) { - if (httpUtils.getQueryParamFromURI(url, param) !== null) { - return param; // Return the first matching nonce parameter - } - } - - return null; // No nonce parameter found - } - - private checkNonceAtResponseLocationHeader( + private checkStateAtResponseLocationHeader( request: Request, response: Response ): string[] | 0 { - const nonceParamName = this.getNonceParamName(request.getUrl() || ""); - if ( - !this.isOauthUri(request) || - !this.isNonceInQuery(request) || - !this.isOauthRedirectResponse(response) || - !nonceParamName + !( + this.isOauthUri(request) && + this.isStateInQuery(request) && + this.isOauthRedirectResponse(response) + ) ) { return 0; // Not a target, no CSRF risk } - // 요청에서 보낸 Nonce 추출 + // 요청에서 보낸 state 추출 const query = request.getQuery() || ""; - const originalNonce = httpUtils.getQueryParam(query, nonceParamName); + const originalState = + httpUtils.getQueryParam(query, "state") || + httpUtils.getQueryParam(query || "", "nonce"); // 리다이렉트 URL에서 쿼리 부분만 추출 - const locationHeader = - httpUtils.getHeaderValue(response.getHeaders(), "location") || ""; - - const responseNonce = httpUtils.getQueryParamFromURI( - locationHeader || "", - nonceParamName - ); - - // Nonce가 없거나, 요청값과 다르면 CSRF 위험 - if (!responseNonce) { - // missing state - return ["Nonce parameter is missing in the response location header"]; - } - if (originalNonce !== responseNonce) { - // mismatch - return ["Nonce parameter mismatch between request and response"]; - } - - return 0; // no CSRF risk detected - } - - private async checkNonceReuse( - sdk: SDK, {}>, - request: Request, - originResponse: Response - ): Promise { - // uri에 oauth 관련 파라미터가 없지만, 응답이 oauth 리다이렉트 응답인지 확인 - // 즉, 처음으로 Nonce를 발급한 요청인지 확인 - if ( - this.isOauthUri(request) || - !this.isOauthRedirectResponse(originResponse) - ) { - return 0; // Not a target, no CSRF risk - } - - // 기존 응답의 location 헤더의 url에서 Nonce 파라미터 이름, nonce 파라미터 값, 쿼리 추출 - const originResponseLocationHeader = - httpUtils.getHeaderValue(originResponse.getHeaders(), "location") || ""; - const nonceParamName = - this.getNonceParamName(originResponseLocationHeader || "") || "state"; - const originLocationQuery = - httpUtils.getQueryFromURI(originResponseLocationHeader || "") || ""; - const originLocationNonce = httpUtils.getQueryParam( - originLocationQuery, - nonceParamName - ); - - // 쿠키가 없는 헤더로 새로운 nonce를 발급받기 위해 요청 - const noCookieHeaders = httpUtils.removeHeaders(request.getHeaders(), [ - "cookie", - ]); - const noCookieResponse = await httpUtils.resend(sdk, request, { - headers: noCookieHeaders, - }); - if (!noCookieResponse || noCookieResponse?.getCode() >= 400) { - return 0; - } - - // 쿠키가 없는 응답의 location 헤더 추출 및 Nonce 추출 - const noCookieLocationHeader = httpUtils.getHeaderValue( - noCookieResponse?.getHeaders() || {}, + const locationHeader = httpUtils.getHeaderValue( + response.getHeaders(), "location" ); - const newNonce = - httpUtils.getQueryParamFromURI( - noCookieLocationHeader || "", - nonceParamName - ) || ""; + const responseState = + httpUtils.getQueryParamFromURI(locationHeader || "", "state") || + httpUtils.getQueryParamFromURI(locationHeader || "", "nonce"); - if (originLocationNonce === newNonce) { - return [ - "State parameter reused in the response location header, indicating a potential CSRF risk", - ]; + // state가 없거나, 요청값과 다르면 CSRF 위험 + if (!responseState) { + // missing state + return ["state parameter is missing in the response location header"]; } - - // 기존 쿠키와 함께 새로운 Nonce로 요청 - const newQuery = httpUtils.setQueryParam( - originLocationQuery, - nonceParamName, - newNonce - ); - - // 기존 location 헤더의 uri 요청과 location 헤더에서 nonce값만 새로 발급한 값으로 바꾸어 요청한 결과를 비교 - const res1 = await httpUtils.customFetch( - sdk, - originResponseLocationHeader, - "GET", - originLocationQuery, - request.getHeaders() - ); - - const res2 = await httpUtils.customFetch( - sdk, - originResponseLocationHeader, - "GET", - newQuery, - request.getHeaders() - ); - - if ( - !res1 || - !res2 || - res1.getCode() >= 400 || - res2.getCode() >= 400 || - res1.getCode() !== res2.getCode() - ) { - return 0; - } - - if ( - res1.getCode() === res2.getCode() && - 300 <= res1.getCode() && - res1.getCode() < 400 - ) { - const res1LocationHeader = - httpUtils.getHeaderValue(res1.getHeaders(), "location") || ""; - const res2LocationHeader = - httpUtils.getHeaderValue(res2.getHeaders(), "location") || ""; - const res1ReirectPath = httpUtils.getPathFromURI(res1LocationHeader); - const res2ReirectPath = httpUtils.getPathFromURI(res2LocationHeader); - - if (res1ReirectPath === res2ReirectPath) { - return [ - "When nonce parameter reused in the response location header, it might not be verified. Indicating a potential CSRF risk", - ]; - } + 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, @@ -234,7 +152,7 @@ export class CsrfCheck { // 쿼리에 state 파라미터가 없으면 CSRF 위험 try { - if (this.isOauthUri(request) && !this.isNonceInQuery(request)) { + if (this.isOauthUri(request) && !this.isStateInQuery(request)) { result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter } } catch (error) { @@ -244,7 +162,7 @@ export class CsrfCheck { // location 헤더에 state 파라미터가 없거나, 요청에서 보낸 state와 다르면 CSRF 위험 try { const stateAtResponseLocationHeaderCheck = - this.checkNonceAtResponseLocationHeader(request, response); + this.checkStateAtResponseLocationHeader(request, response); if (stateAtResponseLocationHeaderCheck !== 0) { result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`; } @@ -254,14 +172,14 @@ export class CsrfCheck { ); } - // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 - const reusedStateCheck = await this.checkNonceReuse(sdk, request, response); - if (reusedStateCheck !== 0) { - result += `, ${reusedStateCheck.join(", ")}`; - } + // // 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기 + // const reusedStateCheck = await this.checkStateReuse(request, response); + // if (reusedStateCheck !== 0) { + // result += `, ${reusedStateCheck.join(", ")}`; + // } + result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas try { - result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas if (result) { await sdk.findings.create({ title: "csrf vuln", @@ -269,6 +187,7 @@ export class CsrfCheck { request, reporter: "csrf reporter", }); + sdk.console.log("qq"); } } catch (error) { sdk.console.error(`Error creating finding: ${error}`); diff --git a/packages/backend/src/controller/scopeDetection.ts b/packages/backend/src/controller/scopeDetection.ts index 9b74dcc..4cdfd79 100644 --- a/packages/backend/src/controller/scopeDetection.ts +++ b/packages/backend/src/controller/scopeDetection.ts @@ -1,83 +1,57 @@ -import type { SDK } from "caido:plugin"; -import { RequestSpec } from "caido:utils"; +import type { Request, Response } from "caido:utils"; +import type { SDK, DefineAPI } from "caido:plugin"; +import { HttpUtils } from "../utils/http"; -export class ScopeDetection { - async scan( - sdk: SDK, - url: string - ): Promise<{ data: string }> { - sdk.console.log(`들어온 url : ${url}`); // url이 잘 들어왔는지 확인함요 +const httpUtils = new HttpUtils(); + + export class ScopeDetection { + // 쿼리 문자열에서 scope 값을 추출하고 값이 없으면 null 반환, 있다면 문자열 반환. + private getScopeFromQuery(query: string | undefined): string | null { + if (!query) return null; + const match = query.match(/(?:^|&)scope=([^&]+)/); + if (match && match[1]) { + return decodeURIComponent(match[1]); + } + return null; + } + // 요청 쿼리 문자열을 받아와서 scope 파라미터 값을 추출하고 값이 all이나 *이면 위험한 scope라고 출력. + async checkScope( + request: Request, + response: Response + ): Promise { - // url이 string이 아니고 , 값이 없거나 그럴 때 유효한 값 넣으라고 출력. - if (!url || typeof url !== "string") { - sdk.console.log("이상한 url 입력함."); - return { data: "알맞은 URL을 입력하세요." }; + const query = request.getQuery() || ""; + const scope = this.getScopeFromQuery(query); + + if (scope && (scope === "all" || scope === "*")) { + return [`요청에서 scope 값 위험 요소 발견: ${scope}`]; } - try { - const spec = new RequestSpec(url); // url에 GET 요청 보낼거긔. - spec.setMethod("GET"); - spec.setHeader("User-Agent", "Caido Scanner"); - spec.setHeader("Accept", "*/*"); - sdk.console.log(`요청 URL: ${url}`); + // 응답 헤더 location에 scope 찾기. + const location = httpUtils.getHeaderValue(response.getHeaders(), "location") || ""; + const locScope = this.getScopeFromQuery(location); - const res = await sdk.requests.send(spec); // 요청 보내고 응답 받음. - sdk.console.log('[SCAN] 응답 :', res); - sdk.console.log(`[SCAN] 요청 성공:${(res as any).status}`); - sdk.console.log(`[SCAN] body: ${(res as any).body ? (res as any).body.toString().substring(0, 100) : "없음"}`); + if (locScope && (locScope === "all" || locScope === "*")) { + return [`응답 location에서 scope 값 위험 요소 발견: ${locScope}`]; + } - const html = (res as any).body ? (res as any).body.toString() : ""; + return 0; + } - // ]*href="([^"]+)"[^>]*>/gi; - const anchors: string[] = []; - let match; - while ((match = anchorRegex.exec(html)) !== null) { // html에서 a href 찾아 배열에 저장함. - if (typeof match[1] === "string") { - anchors.push(match[1]); - } - } - sdk.console.log(`찾아진 a href 개수: ${anchors.length}`); + async checkAndReport( + sdk: any, // DefineAPI 등 구체 타입 맞춰야 함 + request: Request, + response: Response + ) { + const result = await this.checkScope(request, response); // scope 문제 있는지 검사 부분. - // 5. scope 탐지 - const results: string[] = []; - anchors.forEach((href) => { // 추출한 a href 링크 하나씩 검사드감. - try { - const absHref = new URL(href, url).href; // 상대경로라면 url 기준으로 절대 URL 바꿔줌줌 - sdk.console.log(`[SCAN] 절대 URL 변환: ${href} -> ${absHref}`); // - - if (/oauth|authorize|login|accounts|auth/i.test(absHref)) { // url에 이런 OAuth 키워드가 있는지 필터링. - let u: URL; - try { - u = new URL(absHref); // 필터링된 url을 url 객체로 파싱. 정식 url인 경우 변수 u에 저장. - } catch (err) { // 파싱 실패하면 - sdk.console.log( - `URL 파싱 실패 : ${absHref} (${err instanceof Error ? err.message : err})` - ); - return; - } - - try { - const scope = u.searchParams.get("scope"); // url에 scope있긔?scope값 가져와. - if (scope && /all|\*/i.test(scope)) { // scope가 존재하고 all, *있다면. - results.push(`위험한 scope 발견: ${scope}\n -> ${absHref}`); // results에 경고 메시지 전달. - } - } catch (err) { - sdk.console.log(`searchParams.get 실패`); - } - } - } catch (e) { - sdk.console.log( - `URL 파싱 실패 (absHref 단계): ${href} (${e instanceof Error ? e.message : e})` - ); - } + if (result !== 0) { // 문제 있을 경우. + await sdk.findings.create({ + title: "OAuth scope value issue", + description: `${request.getMethod()} ${request.getUrl()}: ${result.join(", ")}`, + request, + reporter: "checker", }); - - const resultStr = results.join("\n") || "위험한 scope가 발견되지 않았습니다."; - return { data: resultStr }; // 성공했는지 실패했는지 App.vue한테 전달할 메시지. - } catch (e) { - sdk.console.log(`백엔드 에러: ${e instanceof Error ? e.message : e}`); - return { data: "백엔드 에러: " + (e instanceof Error ? e.message : String(e)) }; // App.vue에 전달할 메시지. } - }; -} \ No newline at end of file + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a5e9113..43d7516 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -22,6 +22,7 @@ export function init(sdk: SDK) { sdk.events.onInterceptResponse(async (sdk, req: Request, res: Response) => { await csrfCheck.checker(sdk, req, res); //await pkceCheckController.test(sdk, req); + await tokenCheck.testReq(sdk, req); await tokenCheck.testResp(sdk, res, req); await ScopeDetectionController.scan(sdk, req.getUrl()); await redirectBypassController.testAsync(sdk, req, res); @@ -37,7 +38,6 @@ export function init(sdk: SDK) { }); sdk.events.onInterceptRequest(async (sdk, req: Request) => { - await tokenCheck.testReq(sdk, req); await pkceCheckController.test(sdk, req); }); /* diff --git a/packages/backend/src/utils/http.ts b/packages/backend/src/utils/http.ts index 01e2cfc..9fcd741 100644 --- a/packages/backend/src/utils/http.ts +++ b/packages/backend/src/utils/http.ts @@ -1,6 +1,3 @@ -import type { SDK } from "caido:plugin"; -import { Body, RequestSpec, type Request, type Response } from "caido:utils"; - let instance: HttpUtils | null = null; export class HttpUtils { /** @@ -14,14 +11,6 @@ export class HttpUtils { return instance; } - encodeAndLower(value: string): string { - try { - return encodeURIComponent(value).toLowerCase(); - } catch { - return value.toLowerCase(); - } - } - /** * URI 디코딩 후 소문자로 변환하는 헬퍼 함수 * @param value - 디코딩하고 소문자로 변환할 문자열 @@ -58,35 +47,12 @@ export class HttpUtils { return result; } - getPathFromURI(uri: string): string | null { - uri = uri.toLowerCase(); - try { - const urlObj = new URL(uri); - const path = urlObj.pathname; - return path ? decodeURIComponent(path) : null; // 경로가 없으면 null 반환 - } catch (e) { - return null; // URL 파싱 실패 시 null 반환 - } - } - - getQueryFromURI(uri: string): string | null { - uri = uri.toLowerCase(); - try { - const urlObj = new URL(uri); - const query = urlObj.search; - return query ? decodeURIComponent(query.slice(1)) : null; // 쿼리 문자열에서 ? 제거 - } catch (e) { - return null; // URL 파싱 실패 시 null 반환 - } - } - getQueryParamFromURI(uri: string, key: string): string | null { - uri = uri.toLowerCase(); + uri = this.decodeAndLower(uri); key = this.decodeAndLower(key); try { const urlObj = new URL(uri); - const param = urlObj.searchParams.get(key); - return param ? decodeURIComponent(param) : null; + return urlObj.searchParams.get(key); } catch (e) { return null; } @@ -100,12 +66,11 @@ export class HttpUtils { * @returns - 해당 파라미터 값, 없으면 null */ getQueryParam(query: string, key: string): string | null { - query = query.toLowerCase(); + query = this.decodeAndLower(query); key = this.decodeAndLower(key); const params = new URLSearchParams(query); - const targetParam = params.get(key); - return targetParam ? decodeURIComponent(targetParam) : null; + return params.get(key); } /** @@ -117,12 +82,12 @@ export class HttpUtils { * @returns - "a=1&b=2&c=3..." 형태의 새로운 쿼리 문자열 */ setQueryParam(query: string, key: string, value: string): string { - query = query.toLowerCase(); + query = this.decodeAndLower(query); key = this.decodeAndLower(key); value = this.decodeAndLower(value); const params = new URLSearchParams(query); - params.set(key, this.encodeAndLower(value)); + params.set(key, value); return params.toString(); } @@ -134,7 +99,7 @@ export class HttpUtils { * @returns - 삭제된 상태의 새로운 쿼리 문자열 */ removeQueryParam(query: string, key: string): string { - query = query.toLowerCase(); + query = this.decodeAndLower(query); key = this.decodeAndLower(key); const params = new URLSearchParams(query); @@ -144,7 +109,6 @@ export class HttpUtils { // Headers /** - * !! 만약 request.getHeader(`${key}`)을 사용할 수 있다면 이 함수를 사용하지 마세요. * 주어진 헤더 맵에서 name에 해당하는 첫 번째 헤더 값을 반환합니다. * @param headers - Response.getHeaders() 가 반환하는 객체 * @param name - 꺼내고 싶은 헤더 이름 (예: "location", "Content-Type") @@ -243,89 +207,4 @@ export class HttpUtils { } return filtered; } - - async resend( - sdk: SDK, - request: Request, - options?: { - headers?: Record; - body?: Body; - method?: string; - query?: string; - } - ): Promise { - try { - const spec = new RequestSpec(request.getUrl()); - spec.setMethod(options?.method || request.getMethod() || "GET"); - if (options?.query) { - spec.setQuery(options.query); - } else { - spec.setQuery(request.getQuery() || ""); - } - - const originBody = request.getBody(); - if (options?.body) { - spec.setBody(options.body); - } else if (originBody) { - spec.setBody(originBody); - } - - const headers = request.getHeaders(); - if (options?.headers) { - // 기존 헤더에서 options.headers로 덮어쓰기 - const newHeaders = this.lowerCaseAllHeaders({ - ...headers, - ...options.headers, - }); - for (const [key, value] of Object.entries(newHeaders)) { - spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); - } - } else { - // 기존 헤더 그대로 사용 - for (const [key, value] of Object.entries(headers)) { - spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); - } - } - - const result = await sdk.requests.send(spec); - return result.response ?? null; - } catch (error) { - sdk.console.error( - `Error resending request to ${request.getUrl()}: ${String(error)}` - ); - return null; - } - } - - async customFetch( - sdk: SDK, - url: string, - method?: string, - query?: string, - headers?: Record, - body?: Body - ): Promise { - try { - const spec = new RequestSpec(url); - spec.setMethod(method || "GET"); - if (query) { - spec.setQuery(query); - } - if (body) { - spec.setBody(body); - } - - for (const [key, value] of Object.entries(headers || {})) { - spec.setHeader(key, Array.isArray(value) ? value.join(", ") : value); - } - - const result = await sdk.requests.send(spec); - return result.response ?? null; - } catch { - sdk.console.error( - `Error during custom fetch to ${url}: ${String(error)}` - ); - return null; - } - } } diff --git a/playground/csrf/index.js b/playground/csrf/index.js index 01c2bba..5c7a733 100644 --- a/playground/csrf/index.js +++ b/playground/csrf/index.js @@ -1,6 +1,5 @@ // app.js const express = require("express"); -const crypto = require("crypto"); const app = express(); const port = 8000; @@ -44,6 +43,8 @@ app.get("/authorize/mismatch-state", (req, res) => { ); const code = "authcode-67890"; + console.log(`[VULN] original state from client:`, originalState); + // 클라이언트 state와 다르게 'wrong-state'를 삽입 const wrongState = "wrong-state"; const location = `${redirectUri}?code=${code}&state=${wrongState}&client_id=${clientId}`; @@ -51,24 +52,6 @@ app.get("/authorize/mismatch-state", (req, res) => { res.status(302).send(`Redirecting to ${location}`); }); -/** - * 3) 랜덤 state를 생성하여 리다이렉트를 발생시키는 테스트용 엔드포인트 - * - /authorize/reuse-state-test 로 접근할 때마다 새로운 16진수 state를 생성 - * - 최초 요청에 OAuth 파라미터가 없으므로 isOauthUri(request) == false - * - 응답에 Location 헤더로 '...?state=<랜덤값>' 을 포함 - * -> Caido 플러그인의 checkNonceReuse 로직에서 새로운 state가 발급되었는지, - * 재사용되었는지를 검증할 수 있음 - * - 더하여 callback uri에서 해당 nonce의 유효성을 판단하지 않고 응답 시에 vuln - */ -app.get("/authorize/reuse-state-test", (req, res) => { - const state = crypto.randomBytes(16).toString("hex"); - - // 고정된 콜백 URI로 리다이렉트 (OAuth 파라미터는 여기서만 주입) - const location = `http://localhost:${port}/callback?state=${state}&client_id=123`; - res.set("Location", location); - res.status(302).send(`Redirecting to ${location}`); -}); - app.listen(port, () => { console.log( `Vulnerable OAuth test server listening at http://localhost:${port}` @@ -79,7 +62,4 @@ app.listen(port, () => { console.log( `2) Mismatch-State: http://localhost:${port}/authorize/mismatch-state?client_id=abc&state=xyz&redirect_uri=http://localhost:${port}/callback` ); - console.log( - `3) Reuse-State-Test: http://localhost:${port}/authorize/reuse-state-test` - ); });