331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import type { SDK } from "caido:plugin";
|
|
import { Body, RequestSpec, type Request, type Response } from "caido:utils";
|
|
|
|
let instance: HttpUtils | null = null;
|
|
export class HttpUtils {
|
|
/**
|
|
* 싱글턴 인스턴스를 생성합니다.
|
|
*/
|
|
public constructor() {
|
|
if (instance) {
|
|
return instance;
|
|
}
|
|
instance = this;
|
|
return instance;
|
|
}
|
|
|
|
encodeAndLower(value: string): string {
|
|
try {
|
|
return encodeURIComponent(value).toLowerCase();
|
|
} catch {
|
|
return value.toLowerCase();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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();
|
|
key = this.decodeAndLower(key);
|
|
try {
|
|
const urlObj = new URL(uri);
|
|
const param = urlObj.searchParams.get(key);
|
|
return param ? decodeURIComponent(param) : null;
|
|
} 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 = this.decodeAndLower(key);
|
|
|
|
const params = new URLSearchParams(query);
|
|
const targetParam = params.get(key);
|
|
return targetParam ? decodeURIComponent(targetParam) : null;
|
|
}
|
|
|
|
/**
|
|
* 주어진 쿼리 문자열(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 = this.decodeAndLower(key);
|
|
value = this.decodeAndLower(value);
|
|
|
|
const params = new URLSearchParams(query);
|
|
params.set(key, this.encodeAndLower(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 = this.decodeAndLower(key);
|
|
|
|
const params = new URLSearchParams(query);
|
|
params.delete(key);
|
|
return params.toString();
|
|
}
|
|
|
|
// Headers
|
|
/**
|
|
* !! 만약 request.getHeader(`${key}`)을 사용할 수 있다면 이 함수를 사용하지 마세요.
|
|
* 주어진 헤더 맵에서 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;
|
|
}
|
|
|
|
async resend(
|
|
sdk: SDK,
|
|
request: Request,
|
|
options?: {
|
|
headers?: Record<string, string | string[]>;
|
|
body?: Body;
|
|
method?: string;
|
|
query?: string;
|
|
}
|
|
): Promise<Response | null> {
|
|
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<string, string | string[]>,
|
|
body?: Body
|
|
): Promise<Response | null> {
|
|
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;
|
|
}
|
|
}
|
|
}
|