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 형태의 헤더 맵 * @returns - 키와 값이 모두 소문자로 변환된 새 헤더 맵 */ lowerCaseAllHeaders( headers: Record ): Record { const result: Record = {}; 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, 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, name: string, value: string | string[] ): Record { headers = this.lowerCaseAllHeaders(headers); const lowerName = name.toLowerCase(); const newHeaders: Record = {}; // 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, namesToRemove: string | string[] ): Record { headers = this.lowerCaseAllHeaders(headers); const toRemove = Array.isArray(namesToRemove) ? namesToRemove.map((n) => n.toLowerCase()) : [namesToRemove.toLowerCase()]; const filtered: Record = {}; 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; 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; } } }