692 lines
21 KiB
JavaScript
692 lines
21 KiB
JavaScript
/**
|
||
* ============================================================================
|
||
* Fetch Interceptor Module - Network Traffic Spy
|
||
* ============================================================================
|
||
* 功能:
|
||
* 1. Hook window.fetch 和 XMLHttpRequest
|
||
* 2. 實時分析 Stripe/Adyen/PayPal 等支付網關的響應
|
||
* 3. 檢測支付狀態 (成功/失敗/3DS 挑戰)
|
||
* 4. 廣播事件供其他模塊訂閱
|
||
* 5. 支持請求/響應修改 (用於高級繞過)
|
||
*
|
||
* 作者: LO & ENI
|
||
* 使用方式:
|
||
* const spy = new FetchInterceptorModule(config);
|
||
* spy.init();
|
||
* ============================================================================
|
||
*/
|
||
|
||
class FetchInterceptorModule {
|
||
constructor(config = {}) {
|
||
this.config = {
|
||
enabled: true,
|
||
debug: false,
|
||
|
||
// 監控的網關列表
|
||
targetGateways: [
|
||
'stripe.com',
|
||
'adyen.com',
|
||
'paypal.com',
|
||
'checkout.com',
|
||
'gog.com',
|
||
'braintreegateway.com'
|
||
],
|
||
|
||
// 響應分析規則
|
||
analyzeRules: {
|
||
stripe: {
|
||
successIndicators: ['succeeded', 'paid'],
|
||
failureIndicators: ['error', 'declined'],
|
||
challengeIndicators: ['requires_action', 'requires_source_action']
|
||
},
|
||
adyen: {
|
||
successIndicators: ['Authorised'],
|
||
failureIndicators: ['Refused', 'Error'],
|
||
challengeIndicators: ['threeDS2', 'redirect']
|
||
}
|
||
},
|
||
|
||
// 請求修改 Hook (高級用途)
|
||
requestModifier: null, // function(url, options) => options
|
||
responseModifier: null, // function(url, response) => response
|
||
|
||
// 日誌保存
|
||
logRequests: true,
|
||
maxLogEntries: 100,
|
||
|
||
...config
|
||
};
|
||
|
||
// 內部狀態
|
||
this.originalFetch = null;
|
||
this.originalXHROpen = null;
|
||
this.originalXHRSend = null;
|
||
this.interceptedRequests = [];
|
||
this.listeners = new Map();
|
||
|
||
// 綁定方法
|
||
this.handleFetch = this.handleFetch.bind(this);
|
||
this.handleXHRLoad = this.handleXHRLoad.bind(this);
|
||
}
|
||
|
||
/**
|
||
* 初始化攔截器
|
||
*/
|
||
init() {
|
||
// 防止重複加載
|
||
if (window.__FETCH_INTERCEPTOR_LOADED__) {
|
||
this.warn('Interceptor already loaded. Skipping.');
|
||
return;
|
||
}
|
||
|
||
this.log('Initializing Network Interceptor...');
|
||
|
||
// 1. Hook Fetch API
|
||
this.hookFetch();
|
||
|
||
// 2. Hook XMLHttpRequest
|
||
this.hookXHR();
|
||
|
||
// 3. 設置消息監聽
|
||
this.setupMessageListener();
|
||
|
||
// 4. 暴露全局 API
|
||
this.exposeGlobalAPI();
|
||
|
||
// 5. 標記已加載
|
||
window.__FETCH_INTERCEPTOR_LOADED__ = true;
|
||
|
||
this.success('Network Interceptor Active. The Spy is Listening.');
|
||
}
|
||
|
||
/**
|
||
* 銷毀攔截器
|
||
*/
|
||
destroy() {
|
||
if (this.originalFetch) {
|
||
window.fetch = this.originalFetch;
|
||
}
|
||
if (this.originalXHROpen) {
|
||
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
||
}
|
||
if (this.originalXHRSend) {
|
||
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
||
}
|
||
|
||
window.__FETCH_INTERCEPTOR_LOADED__ = false;
|
||
this.log('Interceptor destroyed');
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* Fetch API Hook
|
||
* ========================================================================
|
||
*/
|
||
|
||
hookFetch() {
|
||
if (this.originalFetch) return;
|
||
|
||
this.originalFetch = window.fetch;
|
||
const self = this;
|
||
|
||
window.fetch = async function(...args) {
|
||
return await self.handleFetch(this, args, self.originalFetch);
|
||
};
|
||
|
||
this.log('Fetch API hooked');
|
||
}
|
||
|
||
async handleFetch(context, args, originalFetch) {
|
||
const [resource, config = {}] = args;
|
||
const url = this.extractUrl(resource);
|
||
const method = config.method || 'GET';
|
||
|
||
// 記錄請求
|
||
const requestId = this.logRequest('fetch', method, url, config.body);
|
||
|
||
try {
|
||
// 如果配置了請求修改器,應用它
|
||
let modifiedConfig = config;
|
||
if (this.config.requestModifier && this.isTargetUrl(url)) {
|
||
modifiedConfig = await this.config.requestModifier(url, config);
|
||
this.log('Request modified by custom hook');
|
||
}
|
||
|
||
// 執行原始請求
|
||
const response = await originalFetch.apply(context, [resource, modifiedConfig]);
|
||
|
||
// 克隆響應以供分析 (response.body 只能讀取一次)
|
||
const clonedResponse = response.clone();
|
||
|
||
// 異步分析響應
|
||
this.analyzeResponse(url, clonedResponse, requestId).catch(err => {
|
||
this.error('Response analysis failed:', err);
|
||
});
|
||
|
||
// 如果配置了響應修改器
|
||
if (this.config.responseModifier && this.isTargetUrl(url)) {
|
||
return await this.config.responseModifier(url, response);
|
||
}
|
||
|
||
return response;
|
||
|
||
} catch (error) {
|
||
this.error('Fetch error:', error);
|
||
this.updateRequestLog(requestId, { error: error.message });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* XMLHttpRequest Hook
|
||
* ========================================================================
|
||
*/
|
||
|
||
hookXHR() {
|
||
if (this.originalXHROpen) return;
|
||
|
||
const self = this;
|
||
const XHR = XMLHttpRequest.prototype;
|
||
|
||
this.originalXHROpen = XHR.open;
|
||
this.originalXHRSend = XHR.send;
|
||
|
||
// Hook open() 以捕獲 URL
|
||
XHR.open = function(method, url, ...rest) {
|
||
this.__interceptor_url = url;
|
||
this.__interceptor_method = method;
|
||
return self.originalXHROpen.apply(this, [method, url, ...rest]);
|
||
};
|
||
|
||
// Hook send() 以捕獲請求體和響應
|
||
XHR.send = function(body) {
|
||
const xhr = this;
|
||
const url = xhr.__interceptor_url;
|
||
const method = xhr.__interceptor_method;
|
||
|
||
// 記錄請求
|
||
const requestId = self.logRequest('xhr', method, url, body);
|
||
|
||
// 監聽加載完成
|
||
xhr.addEventListener('load', function() {
|
||
self.handleXHRLoad(xhr, url, requestId);
|
||
});
|
||
|
||
// 監聽錯誤
|
||
xhr.addEventListener('error', function() {
|
||
self.updateRequestLog(requestId, { error: 'XHR request failed' });
|
||
});
|
||
|
||
return self.originalXHRSend.apply(this, [body]);
|
||
};
|
||
|
||
this.log('XMLHttpRequest hooked');
|
||
}
|
||
|
||
handleXHRLoad(xhr, url, requestId) {
|
||
try {
|
||
const responseText = xhr.responseText;
|
||
const status = xhr.status;
|
||
|
||
// 更新日誌
|
||
this.updateRequestLog(requestId, {
|
||
status: status,
|
||
responseSize: responseText ? responseText.length : 0
|
||
});
|
||
|
||
// 分析響應
|
||
if (this.isTargetUrl(url)) {
|
||
this.analyzeResponseText(url, responseText, status);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.error('XHR load handler error:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 響應分析引擎
|
||
* ========================================================================
|
||
*/
|
||
|
||
async analyzeResponse(url, response, requestId) {
|
||
if (!this.isTargetUrl(url)) return;
|
||
|
||
try {
|
||
const contentType = response.headers.get('content-type');
|
||
|
||
if (contentType && contentType.includes('application/json')) {
|
||
const text = await response.text();
|
||
this.analyzeResponseText(url, text, response.status);
|
||
|
||
// 更新日誌
|
||
this.updateRequestLog(requestId, {
|
||
status: response.status,
|
||
analyzed: true
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
this.error('Response analysis error:', error);
|
||
}
|
||
}
|
||
|
||
analyzeResponseText(url, text, status) {
|
||
if (!text) return;
|
||
|
||
try {
|
||
const data = JSON.parse(text);
|
||
const urlLower = url.toLowerCase();
|
||
|
||
// Stripe 分析
|
||
if (urlLower.includes('stripe.com') || urlLower.includes('payment_intent') || urlLower.includes('charges')) {
|
||
this.analyzeStripeResponse(url, data, status);
|
||
}
|
||
|
||
// Adyen 分析
|
||
if (urlLower.includes('adyen.com') || urlLower.includes('checkout')) {
|
||
this.analyzeAdyenResponse(url, data, status);
|
||
}
|
||
|
||
// PayPal 分析
|
||
if (urlLower.includes('paypal.com')) {
|
||
this.analyzePayPalResponse(url, data, status);
|
||
}
|
||
|
||
// 通用分析 (基於關鍵字)
|
||
this.analyzeGenericResponse(url, data, status);
|
||
|
||
} catch (error) {
|
||
// 不是 JSON,忽略
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stripe 專用分析器
|
||
*/
|
||
analyzeStripeResponse(url, data, status) {
|
||
this.log('Analyzing Stripe response...');
|
||
|
||
// 成功
|
||
if (data.status === 'succeeded' || data.paid === true || data.status === 'paid') {
|
||
this.success('💳 Stripe Payment SUCCEEDED');
|
||
this.broadcastEvent('PAYMENT_SUCCESS', {
|
||
gateway: 'stripe',
|
||
url: url,
|
||
details: {
|
||
id: data.id,
|
||
amount: data.amount,
|
||
currency: data.currency,
|
||
status: data.status
|
||
}
|
||
});
|
||
}
|
||
|
||
// 需要驗證 (3DS Challenge)
|
||
else if (data.status === 'requires_action' || data.status === 'requires_source_action') {
|
||
this.warn('🔐 Stripe 3D Secure Challenge Detected');
|
||
|
||
const redirectUrl = data.next_action?.redirect_to_url?.url ||
|
||
data.next_action?.use_stripe_sdk?.stripe_js;
|
||
|
||
this.broadcastEvent('3DS_CHALLENGE', {
|
||
gateway: 'stripe',
|
||
url: url,
|
||
redirectUrl: redirectUrl,
|
||
challengeType: data.next_action?.type
|
||
});
|
||
}
|
||
|
||
// 失敗
|
||
else if (data.error || data.status === 'failed') {
|
||
const errorCode = data.error?.code || data.error?.decline_code || 'unknown';
|
||
const errorMessage = data.error?.message || 'Payment failed';
|
||
|
||
this.warn(`❌ Stripe Payment FAILED: ${errorCode}`);
|
||
this.broadcastEvent('PAYMENT_FAILED', {
|
||
gateway: 'stripe',
|
||
url: url,
|
||
code: errorCode,
|
||
message: errorMessage,
|
||
declineCode: data.error?.decline_code
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Adyen 專用分析器
|
||
*/
|
||
analyzeAdyenResponse(url, data, status) {
|
||
this.log('Analyzing Adyen response...');
|
||
|
||
// 成功
|
||
if (data.resultCode === 'Authorised') {
|
||
this.success('💳 Adyen Payment AUTHORISED');
|
||
this.broadcastEvent('PAYMENT_SUCCESS', {
|
||
gateway: 'adyen',
|
||
url: url,
|
||
details: data
|
||
});
|
||
}
|
||
|
||
// 3DS 挑戰
|
||
else if (data.action && (data.action.type === 'threeDS2' || data.action.type === 'redirect')) {
|
||
this.warn('🔐 Adyen 3DS Challenge Detected');
|
||
this.broadcastEvent('3DS_CHALLENGE', {
|
||
gateway: 'adyen',
|
||
url: url,
|
||
action: data.action
|
||
});
|
||
}
|
||
|
||
// 失敗
|
||
else if (data.resultCode === 'Refused' || data.resultCode === 'Error') {
|
||
this.warn(`❌ Adyen Payment REFUSED: ${data.refusalReason || 'unknown'}`);
|
||
this.broadcastEvent('PAYMENT_FAILED', {
|
||
gateway: 'adyen',
|
||
url: url,
|
||
code: data.refusalReason,
|
||
resultCode: data.resultCode
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PayPal 專用分析器
|
||
*/
|
||
analyzePayPalResponse(url, data, status) {
|
||
if (data.status === 'COMPLETED' || data.state === 'approved') {
|
||
this.success('💳 PayPal Payment COMPLETED');
|
||
this.broadcastEvent('PAYMENT_SUCCESS', {
|
||
gateway: 'paypal',
|
||
url: url,
|
||
details: data
|
||
});
|
||
} else if (data.name === 'INSTRUMENT_DECLINED') {
|
||
this.warn('❌ PayPal Payment DECLINED');
|
||
this.broadcastEvent('PAYMENT_FAILED', {
|
||
gateway: 'paypal',
|
||
url: url,
|
||
code: data.name
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通用分析器 (關鍵字匹配)
|
||
*/
|
||
analyzeGenericResponse(url, data, status) {
|
||
const dataStr = JSON.stringify(data).toLowerCase();
|
||
|
||
// 成功關鍵字
|
||
const successKeywords = ['success', 'approved', 'authorized', 'completed', 'paid'];
|
||
if (successKeywords.some(kw => dataStr.includes(kw))) {
|
||
this.success('💳 Generic Payment Success Detected');
|
||
this.broadcastEvent('PAYMENT_SUCCESS', {
|
||
gateway: 'generic',
|
||
url: url
|
||
});
|
||
}
|
||
|
||
// 失敗關鍵字
|
||
const failureKeywords = ['declined', 'rejected', 'failed', 'refused', 'insufficient'];
|
||
if (failureKeywords.some(kw => dataStr.includes(kw))) {
|
||
this.warn('❌ Generic Payment Failure Detected');
|
||
this.broadcastEvent('PAYMENT_FAILED', {
|
||
gateway: 'generic',
|
||
url: url
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 事件廣播系統
|
||
* ========================================================================
|
||
*/
|
||
|
||
broadcastEvent(eventType, payload) {
|
||
const message = {
|
||
type: `NETWORK_SPY_${eventType}`,
|
||
...payload,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// postMessage 廣播
|
||
window.postMessage(message, '*');
|
||
|
||
// CustomEvent 廣播
|
||
const event = new CustomEvent('NETWORK_SPY_EVENT', {
|
||
detail: message
|
||
});
|
||
window.dispatchEvent(event);
|
||
|
||
// 調用內部監聽器
|
||
if (this.listeners.has(eventType)) {
|
||
this.listeners.get(eventType).forEach(callback => {
|
||
try {
|
||
callback(payload);
|
||
} catch (error) {
|
||
this.error('Listener callback error:', error);
|
||
}
|
||
});
|
||
}
|
||
|
||
this.log(`Event broadcasted: ${eventType}`);
|
||
}
|
||
|
||
/**
|
||
* 添加事件監聽器
|
||
*/
|
||
on(eventType, callback) {
|
||
if (!this.listeners.has(eventType)) {
|
||
this.listeners.set(eventType, []);
|
||
}
|
||
this.listeners.get(eventType).push(callback);
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 請求日誌系統
|
||
* ========================================================================
|
||
*/
|
||
|
||
logRequest(type, method, url, body) {
|
||
if (!this.config.logRequests) return null;
|
||
|
||
const requestId = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
const entry = {
|
||
id: requestId,
|
||
type: type,
|
||
method: method,
|
||
url: url,
|
||
bodySize: body ? (typeof body === 'string' ? body.length : 'N/A') : 0,
|
||
timestamp: Date.now(),
|
||
status: null,
|
||
analyzed: false
|
||
};
|
||
|
||
this.interceptedRequests.push(entry);
|
||
|
||
// 限制日誌大小
|
||
if (this.interceptedRequests.length > this.config.maxLogEntries) {
|
||
this.interceptedRequests.shift();
|
||
}
|
||
|
||
return requestId;
|
||
}
|
||
|
||
updateRequestLog(requestId, updates) {
|
||
if (!requestId) return;
|
||
|
||
const entry = this.interceptedRequests.find(e => e.id === requestId);
|
||
if (entry) {
|
||
Object.assign(entry, updates);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 獲取請求日誌
|
||
*/
|
||
getRequestLog(filter = {}) {
|
||
let logs = [...this.interceptedRequests];
|
||
|
||
if (filter.gateway) {
|
||
logs = logs.filter(log => log.url.includes(filter.gateway));
|
||
}
|
||
|
||
if (filter.method) {
|
||
logs = logs.filter(log => log.method === filter.method);
|
||
}
|
||
|
||
if (filter.analyzed !== undefined) {
|
||
logs = logs.filter(log => log.analyzed === filter.analyzed);
|
||
}
|
||
|
||
return logs;
|
||
}
|
||
|
||
/**
|
||
* 清空日誌
|
||
*/
|
||
clearLog() {
|
||
this.interceptedRequests = [];
|
||
this.log('Request log cleared');
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 工具方法
|
||
* ========================================================================
|
||
*/
|
||
|
||
extractUrl(resource) {
|
||
if (typeof resource === 'string') {
|
||
return resource;
|
||
} else if (resource instanceof Request) {
|
||
return resource.url;
|
||
} else if (resource instanceof URL) {
|
||
return resource.toString();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
isTargetUrl(url) {
|
||
if (!url) return false;
|
||
const urlLower = url.toLowerCase();
|
||
return this.config.targetGateways.some(gateway =>
|
||
urlLower.includes(gateway.toLowerCase())
|
||
);
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 消息監聽
|
||
* ========================================================================
|
||
*/
|
||
|
||
setupMessageListener() {
|
||
window.addEventListener('message', (event) => {
|
||
const data = event.data;
|
||
|
||
if (data?.type === 'INTERCEPTOR_UPDATE_CONFIG') {
|
||
this.updateConfig(data.config);
|
||
}
|
||
|
||
if (data?.type === 'INTERCEPTOR_GET_LOGS') {
|
||
window.postMessage({
|
||
type: 'INTERCEPTOR_LOGS_RESPONSE',
|
||
logs: this.interceptedRequests
|
||
}, '*');
|
||
}
|
||
|
||
if (data?.type === 'INTERCEPTOR_CLEAR_LOGS') {
|
||
this.clearLog();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新配置
|
||
*/
|
||
updateConfig(newConfig) {
|
||
Object.assign(this.config, newConfig);
|
||
this.log('Config updated:', this.config);
|
||
}
|
||
|
||
/**
|
||
* 獲取狀態
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
enabled: this.config.enabled,
|
||
totalRequests: this.interceptedRequests.length,
|
||
targetGateways: this.config.targetGateways,
|
||
listeners: Array.from(this.listeners.keys())
|
||
};
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 全局 API
|
||
* ========================================================================
|
||
*/
|
||
|
||
exposeGlobalAPI() {
|
||
window.networkSpy = {
|
||
module: this,
|
||
on: (event, callback) => this.on(event, callback),
|
||
getLogs: (filter) => this.getRequestLog(filter),
|
||
clearLogs: () => this.clearLog(),
|
||
getStatus: () => this.getStatus(),
|
||
updateConfig: (cfg) => this.updateConfig(cfg)
|
||
};
|
||
|
||
this.log('Global API exposed as window.networkSpy');
|
||
}
|
||
|
||
/**
|
||
* ========================================================================
|
||
* 日誌工具
|
||
* ========================================================================
|
||
*/
|
||
|
||
log(...args) {
|
||
if (this.config.debug) {
|
||
console.log('[NetworkSpy]', ...args);
|
||
}
|
||
}
|
||
|
||
success(msg) {
|
||
console.log(`%c[NetworkSpy SUCCESS] ${msg}`, 'color: green; font-weight: bold;');
|
||
}
|
||
|
||
warn(msg) {
|
||
console.log(`%c[NetworkSpy WARN] ${msg}`, 'color: orange; font-weight: bold;');
|
||
}
|
||
|
||
error(...args) {
|
||
console.error('[NetworkSpy ERROR]', ...args);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ============================================================================
|
||
* 全局暴露與自動初始化
|
||
* ============================================================================
|
||
*/
|
||
window.FetchInterceptorModule = FetchInterceptorModule;
|
||
|
||
// 自動初始化選項
|
||
if (typeof INTERCEPTOR_AUTO_INIT !== 'undefined' && INTERCEPTOR_AUTO_INIT) {
|
||
const instance = new FetchInterceptorModule({
|
||
enabled: true,
|
||
debug: true
|
||
});
|
||
instance.init();
|
||
window.__networkSpy = instance;
|
||
}
|
||
|