caido-plugin-test/packages/backend/src/controller/csrfCheck.ts

277 lines
8.3 KiB
TypeScript

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 nonceParam = [
"state",
"nonce",
"as",
"frame_id",
"csrf_token",
"csrf",
];
private isTargetUri(uri: string): boolean {
if (
httpUtils.getQueryParamFromURI(uri, "client_id") !== null &&
(httpUtils.getQueryParamFromURI(uri, "response_type") !== null ||
httpUtils.getQueryParamFromURI(uri, "grant_type") !== null ||
httpUtils.getQueryParamFromURI(uri, "redirect_uri") !== null ||
httpUtils.getQueryParamFromURI(uri, "scope") !== null ||
httpUtils.getQueryParamFromURI(uri, "state") !== null ||
httpUtils.getQueryParamFromURI(uri, "nonce") !== null)
) {
return true;
}
return false;
}
private isOauthUri(request: Request): boolean {
const uri = request.getUrl() || "";
// Check if the request is an OAuth authorization request
if (this.isTargetUri(uri)) {
return true;
}
return false;
}
private isOauthRedirectResponse(response: Response): boolean {
const status = response.getCode();
const uri =
httpUtils.getHeaderValue(response.getHeaders(), "location") || "";
if (status >= 300 && status < 400 && this.isTargetUri(uri)) {
return true;
}
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
}
}
return false; // No nonce parameter found in the query
}
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(
request: Request,
response: Response
): string[] | 0 {
const nonceParamName = this.getNonceParamName(request.getUrl() || "");
if (
!this.isOauthUri(request) ||
!this.isNonceInQuery(request) ||
!this.isOauthRedirectResponse(response) ||
!nonceParamName
) {
return 0; // Not a target, no CSRF risk
}
// 요청에서 보낸 Nonce 추출
const query = request.getQuery() || "";
const originalNonce = httpUtils.getQueryParam(query, nonceParamName);
// 리다이렉트 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<DefineAPI<{}>, {}>,
request: Request,
originResponse: Response
): Promise<string[] | 0> {
// 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() || {},
"location"
);
const newNonce =
httpUtils.getQueryParamFromURI(
noCookieLocationHeader || "",
nonceParamName
) || "";
if (originLocationNonce === newNonce) {
return [
"State parameter reused in the response location header, indicating a potential CSRF risk",
];
}
// 기존 쿠키와 함께 새로운 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",
];
}
}
return 0; // no CSRF risk detected
}
async checker(
sdk: SDK<DefineAPI<{}>, {}>,
request: Request,
response: Response
): Promise<void> {
let result = ``;
// 쿼리에 state 파라미터가 없으면 CSRF 위험
try {
if (this.isOauthUri(request) && !this.isNonceInQuery(request)) {
result += "CSRF risk: missing state parameter"; // CSRF risk: missing state parameter
}
} catch (error) {
sdk.console.error(`Error checking state in query: ${error}`);
}
// location 헤더에 state 파라미터가 없거나, 요청에서 보낸 state와 다르면 CSRF 위험
try {
const stateAtResponseLocationHeaderCheck =
this.checkNonceAtResponseLocationHeader(request, response);
if (stateAtResponseLocationHeaderCheck !== 0) {
result += `, ${stateAtResponseLocationHeaderCheck.join(", ")}`;
}
} catch (error) {
sdk.console.error(
`Error checking state in response location header: ${error}`
);
}
// 처음으로 state를 발급한 요청에서 state 파라미터를 바꿔서 보내기
const reusedStateCheck = await this.checkNonceReuse(sdk, request, response);
if (reusedStateCheck !== 0) {
result += `, ${reusedStateCheck.join(", ")}`;
}
try {
result.replace(/^\s*,\s*|\s*$/, ""); // Remove leading/trailing commas
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",
});
}
} catch (error) {
sdk.console.error(`Error creating finding: ${error}`);
}
}
}