Merge branch 'main' into feature/scope

This commit is contained in:
암냥 (imnyang) 2025-05-31 12:01:58 +09:00 committed by GitHub
commit 307d373b9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1841 additions and 38 deletions

View file

@ -0,0 +1,181 @@
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<void> {
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) {
await sdk.findings.create({
title: "csrf vuln",
description: `SSO-related parameters detected in response:\n\n${request.getMethod()} ${request.getUrl()} : ${result}`,
request,
reporter: "csrf reporter",
});
}
}
}

View file

@ -1,52 +1,53 @@
import type { SDK, DefineAPI } from "caido:plugin";
import type { Request } from "caido:utils";
import { ImplicitGrantController } from "./controller/implictGrant";
import { AuthZCodeGrantController } from "./controller/authZCodeGrant";
import type { Request, Response } from "caido:utils";
// import { ImplicitGrantController } from "./controller/implictGrant";
// import { AuthZCodeGrantController } from "./controller/authZCodeGrant";
import { CsrfCheck } from "./controller/csrfCheck";
import { PKCECheck } from "./controller/PKCECheck";
import { ScopeDetection } from "./controller/scopeDetection";
export type API = DefineAPI<{}>;
const implicitGrantController = new ImplicitGrantController();
const authZCodeGrantController = new AuthZCodeGrantController();
const csrfCheck = new CsrfCheck();
// const implicitGrantController = new ImplicitGrantController();
// const authZCodeGrantController = new AuthZCodeGrantController();
const pkceCheckController = new PKCECheck();
const ScopeDetectionController = new ScopeDetection();
// function matchSSORequest(req: Request): boolean {
// const raw = req.getRaw().toString();
// // 조건 3: Raw request에 SAMLRequest 또는 SAMLResponse 포함
// if (raw.includes("SAMLRequest=") || raw.includes("SAMLResponse=")) {
// return true;
// }
// return false;
// }
// function matchAccessTokenResponse(resp: Response): boolean {
// const raw = resp.getRaw().toString();
// const match = /"access_token"\s*:\s*"([^"]+)"/.exec(raw);
// return !!match;
// }
export function init(sdk: SDK<API>) {
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) {
// 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: SDK<DefineAPI<{}>, {}>, req: Request, resp: Response) => {
await csrfCheck.checker(sdk, req, resp);
await pkceCheckController.test(sdk, req);
await ScopeDetectionController.scan(sdk, req.getUrl());
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.onInterceptRequest(async (sdk, req: Request) => {
// const result =
// authZCodeGrantController.testReq(req) ||
// implicitGrantController.testReq(req);
});
// if (result) {
// await pkceCheckController.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}`,
// request: req,
// reporter: "",
// });
}
);
}

View file

@ -0,0 +1,210 @@
let instance: HttpUtils | null = null;
export class HttpUtils {
/**
* .
*/
public constructor() {
if (instance) {
return instance;
}
instance = this;
return instance;
}
/**
* URI
* @param value -
* @returns
*/
decodeAndLower(value: string): string {
try {
return decodeURIComponent(value).toLowerCase();
} catch {
return value.toLowerCase();
}
}
/**
* .
* @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 = this.decodeAndLower(rawKey);
if (Array.isArray(rawValue)) {
result[key] = rawValue.map((v) => this.decodeAndLower(v));
} else {
result[key] = this.decodeAndLower(rawValue);
}
}
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 {
const normalized = this.lowerCaseAllHeaders(headers);
const target = name.toLowerCase();
for (const [key, value] of Object.entries(normalized)) {
if (key === target) {
let rawValue: string | null = null;
if (Array.isArray(value)) {
rawValue = value.length > 0 && value[0] ? value[0] : null;
} else {
rawValue = value.length > 0 ? value : null;
}
if (rawValue !== null) {
try {
return decodeURIComponent(rawValue);
} catch {
return rawValue;
}
}
}
}
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;
}
}