csrf(state) 관련 취약점 탐지 기능 추가
This commit is contained in:
parent
11b6e479dd
commit
e868cbec67
5 changed files with 400 additions and 7 deletions
BIN
dist/plugin_package.zip
vendored
Normal file
BIN
dist/plugin_package.zip
vendored
Normal file
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "caido-oauth",
|
"name": "caido-oauth-dev",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
178
packages/backend/src/controller/csrfCheck.ts
Normal file
178
packages/backend/src/controller/csrfCheck.ts
Normal file
|
|
@ -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<string[] | 0> {
|
||||||
|
// // 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<DefineAPI<{}>, {}>,
|
||||||
|
request: Request,
|
||||||
|
response: Response
|
||||||
|
): Promise<string | 0> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { SDK, DefineAPI } from "caido:plugin";
|
import type { SDK, DefineAPI } from "caido:plugin";
|
||||||
import type { Request } from "caido:utils";
|
import type { Request } from "caido:utils";
|
||||||
|
<<<<<<< HEAD
|
||||||
import { ImplicitGrantController } from "./controller/implictGrant";
|
import { ImplicitGrantController } from "./controller/implictGrant";
|
||||||
import { AuthZCodeGrantController } from "./controller/authZCodeGrant";
|
import { AuthZCodeGrantController } from "./controller/authZCodeGrant";
|
||||||
import { PKCECheck } from "./controller/PKCECheck";
|
import { PKCECheck } from "./controller/PKCECheck";
|
||||||
|
|
@ -27,19 +28,40 @@ const pkceCheck = new PKCECheck();
|
||||||
// const match = /"access_token"\s*:\s*"([^"]+)"/.exec(raw);
|
// const match = /"access_token"\s*:\s*"([^"]+)"/.exec(raw);
|
||||||
// return !!match;
|
// 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<API>) {
|
export function init(sdk: SDK<API>) {
|
||||||
sdk.events.onInterceptRequest(async (sdk, req: Request) => {
|
// sdk.events.onInterceptRequest(async (sdk, req: Request) => {
|
||||||
const result =
|
// const result = csrfCheck.checker(req);
|
||||||
authZCodeGrantController.testReq(req) ||
|
|
||||||
implicitGrantController.testReq(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) {
|
if (result) {
|
||||||
await pkceCheck.test(sdk, req);
|
await pkceCheck.test(sdk, req);
|
||||||
|
|
||||||
await sdk.findings.create({
|
await sdk.findings.create({
|
||||||
title: "Possible SSO Request Detected",
|
title: "Possible SSO Response Detected",
|
||||||
description: `SSO-related parameters detected in request:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`,
|
description: `SSO-related parameters detected in response:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`,
|
||||||
request: req,
|
request: req,
|
||||||
reporter: "",
|
reporter: "",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
193
packages/backend/src/utils/http.ts
Normal file
193
packages/backend/src/utils/http.ts
Normal file
|
|
@ -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<string, string | string[]> 형태의 헤더 맵
|
||||||
|
* @returns - 키와 값이 모두 소문자로 변환된 새 헤더 맵
|
||||||
|
*/
|
||||||
|
lowerCaseAllHeaders(
|
||||||
|
headers: Record<string, string | string[]>
|
||||||
|
): Record<string, string | string[]> {
|
||||||
|
const result: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
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<string, string | string[]>,
|
||||||
|
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<string, string | string[]>,
|
||||||
|
name: string,
|
||||||
|
value: string | string[]
|
||||||
|
): Record<string, string[]> {
|
||||||
|
headers = this.lowerCaseAllHeaders(headers);
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
const newHeaders: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
// 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<string, string | string[]>,
|
||||||
|
namesToRemove: string | string[]
|
||||||
|
): Record<string, string[]> {
|
||||||
|
headers = this.lowerCaseAllHeaders(headers);
|
||||||
|
const toRemove = Array.isArray(namesToRemove)
|
||||||
|
? namesToRemove.map((n) => n.toLowerCase())
|
||||||
|
: [namesToRemove.toLowerCase()];
|
||||||
|
|
||||||
|
const filtered: Record<string, string[]> = {};
|
||||||
|
for (const [key, vals] of Object.entries(headers)) {
|
||||||
|
if (!toRemove.includes(key.toLowerCase())) {
|
||||||
|
filtered[key] = Array.isArray(vals) ? vals : [vals];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue