KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import { buildOrderRequestHeaders } from './guestOrderToken';
|
|
|
|
const PLUGIN_ID = 'sirsoft-pay_nicepayments';
|
|
const FLAG = '__nicepayOrderShowInjectorInstalled';
|
|
const ROW_ID = 'nicepay-mp-receipt-row';
|
|
const VBANK_BLOCK_ID = 'nicepay-mp-vbank-info';
|
|
|
|
// 회원 마이페이지(/mypage/orders/{N}) 와 비회원 주문 상세(/shop/guest/orders/{N}) 를 함께 매칭한다.
|
|
// 비회원 경로를 빼면 그 화면에서는 영수증 정보가 조회조차 되지 않는다 (서버는 지원한다).
|
|
const ORDER_SHOW_RE = /^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/;
|
|
|
|
interface Payment {
|
|
pg_provider?: string;
|
|
payment_method?: string;
|
|
transaction_id?: string | null;
|
|
paid_at?: string | null;
|
|
vbank_name?: string | null;
|
|
vbank_number?: string | null;
|
|
vbank_holder?: string | null;
|
|
vbank_due_at?: string | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface OrderData {
|
|
order_number?: string;
|
|
total_amount_formatted?: string;
|
|
payment?: Payment;
|
|
}
|
|
|
|
interface ReceiptInfo {
|
|
receipt_url?: string | null;
|
|
payment_method_display_label?: string | null;
|
|
}
|
|
|
|
function getOrderFromState(orderNumber: string): OrderData | null {
|
|
try {
|
|
const g7 = (window as Record<string, unknown>).G7Core as Record<string, unknown> | undefined;
|
|
const getState = g7?.getState as (() => Record<string, unknown>) | undefined;
|
|
const ctx = getState?.()?.currentDataContext as Record<string, unknown> | undefined;
|
|
const order = ctx?.order as { data?: OrderData } | undefined;
|
|
const data = order?.data;
|
|
if (!data || data.order_number !== orderNumber) return null;
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
async function fetchReceiptInfo(orderNumber: string): Promise<ReceiptInfo | null> {
|
|
const headers = buildOrderRequestHeaders();
|
|
if (!headers) return null;
|
|
try {
|
|
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
|
|
headers,
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!res.ok) return null;
|
|
return (await res.json()) as ReceiptInfo;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function patchPaymentMethodDisplay(container: Element, displayLabel: string | null | undefined): boolean {
|
|
if (!displayLabel) return false;
|
|
|
|
const rows = Array.from(container.querySelectorAll<HTMLElement>('div'));
|
|
for (const row of rows) {
|
|
const spans = Array.from(row.children).filter(
|
|
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName === 'SPAN',
|
|
);
|
|
if (spans.length < 2) continue;
|
|
|
|
const label = spans[0].textContent?.trim();
|
|
if (label !== '결제 방법' && label !== '결제수단' && label !== 'Payment Method') continue;
|
|
|
|
const value = spans[spans.length - 1];
|
|
if (value.textContent?.trim() !== displayLabel) {
|
|
value.textContent = displayLabel;
|
|
value.dataset.nicepayPaymentMethodPatched = 'true';
|
|
}
|
|
row.dataset.nicepayPaymentMethodRow = 'true';
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function findPaymentContainer(): Element | null {
|
|
const panel = document.getElementById('order_payment_info_panel');
|
|
if (panel) {
|
|
return Array.from(panel.children).find(el => el.className?.includes('space-y')) ?? panel;
|
|
}
|
|
|
|
const h3 = Array.from(document.querySelectorAll<HTMLElement>('h3')).find(
|
|
el => el.textContent?.includes('결제 정보'),
|
|
);
|
|
if (!h3) return null;
|
|
|
|
const panelDiv = h3.parentElement?.parentElement;
|
|
if (!panelDiv) return null;
|
|
|
|
return Array.from(panelDiv.children).find(el => el.className?.includes('space-y')) ?? panelDiv;
|
|
}
|
|
|
|
function buildReceiptRow(orderNumber: string): HTMLElement {
|
|
const row = document.createElement('div');
|
|
row.id = ROW_ID;
|
|
row.className = 'flex items-center justify-between';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'text-gray-500 dark:text-gray-400 text-sm';
|
|
label.textContent = '영수증';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className =
|
|
'inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50';
|
|
btn.textContent = '영수증 조회';
|
|
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = '로딩 중...';
|
|
const data = await fetchReceiptInfo(orderNumber);
|
|
btn.disabled = false;
|
|
btn.textContent = '영수증 조회';
|
|
const url = data?.receipt_url ?? null;
|
|
if (url) {
|
|
window.open(url, 'nicepay_receipt', 'width=800,height=600,scrollbars=yes,resizable=yes');
|
|
}
|
|
});
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(btn);
|
|
return row;
|
|
}
|
|
|
|
function formatVbankDueAt(raw: string | null | undefined): string {
|
|
if (!raw) return '';
|
|
try {
|
|
const d = new Date(raw);
|
|
if (Number.isNaN(d.getTime())) return raw;
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
function buildVbankRow(label: string, value: string, valueClass = 'font-medium text-gray-900 dark:text-white'): HTMLElement {
|
|
const row = document.createElement('div');
|
|
row.className = 'flex justify-between text-sm';
|
|
const l = document.createElement('span');
|
|
l.className = 'text-gray-600 dark:text-gray-400';
|
|
l.textContent = label;
|
|
const v = document.createElement('span');
|
|
v.className = valueClass;
|
|
v.textContent = value;
|
|
row.appendChild(l);
|
|
row.appendChild(v);
|
|
return row;
|
|
}
|
|
|
|
function buildVbankBlock(orderData: OrderData): HTMLElement {
|
|
const p = orderData.payment ?? {};
|
|
const wrap = document.createElement('div');
|
|
wrap.id = VBANK_BLOCK_ID;
|
|
wrap.className = 'pt-4 mt-2 border-t border-gray-200 dark:border-gray-700';
|
|
|
|
const inner = document.createElement('div');
|
|
inner.className = 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4';
|
|
|
|
const title = document.createElement('h3');
|
|
title.className = 'text-sm font-semibold text-blue-800 dark:text-blue-300 mb-3';
|
|
title.textContent = '가상계좌 입금 안내';
|
|
|
|
const rows = document.createElement('div');
|
|
rows.className = 'space-y-2';
|
|
rows.appendChild(buildVbankRow('은행', p.vbank_name ?? ''));
|
|
rows.appendChild(buildVbankRow('계좌번호', p.vbank_number ?? '', 'font-medium font-mono text-gray-900 dark:text-white'));
|
|
rows.appendChild(buildVbankRow('예금주', p.vbank_holder ?? ''));
|
|
rows.appendChild(buildVbankRow('입금 금액', orderData.total_amount_formatted ?? '', 'font-bold text-blue-600 dark:text-blue-400'));
|
|
|
|
const dueRaw = p.vbank_due_at;
|
|
if (dueRaw) {
|
|
const dueRow = document.createElement('div');
|
|
dueRow.className = 'flex justify-between text-sm pt-2 border-t border-blue-200 dark:border-blue-700 mt-2';
|
|
const dueLabel = document.createElement('span');
|
|
dueLabel.className = 'text-gray-600 dark:text-gray-400';
|
|
dueLabel.textContent = '입금 기한';
|
|
const dueValue = document.createElement('span');
|
|
dueValue.className = 'font-medium text-red-600 dark:text-red-400';
|
|
dueValue.textContent = formatVbankDueAt(dueRaw);
|
|
dueRow.appendChild(dueLabel);
|
|
dueRow.appendChild(dueValue);
|
|
rows.appendChild(dueRow);
|
|
}
|
|
|
|
inner.appendChild(title);
|
|
inner.appendChild(rows);
|
|
|
|
if (!p.paid_at) {
|
|
const notice = document.createElement('p');
|
|
notice.className = 'text-xs text-blue-700 dark:text-blue-300 mt-3';
|
|
notice.textContent = '입금 기한 내에 입금이 완료되지 않으면 주문이 자동 취소됩니다.';
|
|
inner.appendChild(notice);
|
|
}
|
|
|
|
wrap.appendChild(inner);
|
|
return wrap;
|
|
}
|
|
|
|
async function tryInject(orderNumber: string): Promise<boolean> {
|
|
const orderData = getOrderFromState(orderNumber);
|
|
if (!orderData) return false;
|
|
|
|
const { payment } = orderData;
|
|
if (!payment || payment.pg_provider !== 'nicepayments') return true;
|
|
|
|
const container = findPaymentContainer();
|
|
if (!container) return false;
|
|
const receiptInfo = await fetchReceiptInfo(orderNumber);
|
|
patchPaymentMethodDisplay(container, receiptInfo?.payment_method_display_label);
|
|
|
|
// 가상계좌 입금 안내 — 결제수단이 vbank 이고 가상계좌 번호 발급된 경우 표시 (paid_at 무관: 입금 전후 모두 표시).
|
|
if (payment.payment_method === 'vbank' && payment.vbank_number && !document.getElementById(VBANK_BLOCK_ID)) {
|
|
container.appendChild(buildVbankBlock(orderData));
|
|
console.info(`[${PLUGIN_ID}] vbank info injected on mypage order show`);
|
|
}
|
|
|
|
if (!payment.transaction_id) return true;
|
|
// 영수증 버튼은 결제완료(paid_at 채워짐) 시점에만 표시 — 가상계좌 입금대기 차단
|
|
if (!payment.paid_at) return true;
|
|
|
|
if (document.getElementById(ROW_ID)) return true;
|
|
|
|
container.appendChild(buildReceiptRow(orderNumber));
|
|
console.info(`[${PLUGIN_ID}] receipt button injected on mypage order show`);
|
|
return true;
|
|
}
|
|
|
|
function startPolling(orderNumber: string): void {
|
|
let attempts = 0;
|
|
const id = setInterval(() => {
|
|
attempts++;
|
|
void tryInject(orderNumber).then(done => {
|
|
if (done || attempts >= 30) clearInterval(id);
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
function onRouteChange(): void {
|
|
const match = location.pathname.match(ORDER_SHOW_RE);
|
|
// 회원 그룹(match[1]) 또는 비회원 그룹(match[2]) 중 한쪽이 채워진다.
|
|
const segment = match?.[1] ?? match?.[2];
|
|
if (segment) startPolling(segment);
|
|
}
|
|
|
|
export function installMypageOrderShowInjector(): void {
|
|
if (typeof window === 'undefined') return;
|
|
const w = window as unknown as Record<string, unknown>;
|
|
if (w[FLAG]) return;
|
|
w[FLAG] = true;
|
|
|
|
console.info(`[${PLUGIN_ID}] mypage order show injector installed`);
|
|
|
|
const schedule = (delay = 1500) => setTimeout(onRouteChange, delay);
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => schedule());
|
|
} else {
|
|
schedule();
|
|
}
|
|
|
|
const origPush = history.pushState.bind(history);
|
|
history.pushState = (...args: Parameters<typeof history.pushState>) => {
|
|
origPush(...args);
|
|
schedule(600);
|
|
};
|
|
window.addEventListener('popstate', () => schedule(500));
|
|
}
|