550 lines
17 KiB
JavaScript
550 lines
17 KiB
JavaScript
const storagePrefix = 'webhook-inspector:xhr-redirect:';
|
|
const forwardedRequestIds = new Set();
|
|
const inMemoryConfigurations = new Map();
|
|
const forbiddenRequestHeaders = new Set([
|
|
'accept-charset',
|
|
'accept-encoding',
|
|
'access-control-request-headers',
|
|
'access-control-request-method',
|
|
'connection',
|
|
'content-length',
|
|
'cookie',
|
|
'cookie2',
|
|
'date',
|
|
'dnt',
|
|
'expect',
|
|
'host',
|
|
'keep-alive',
|
|
'origin',
|
|
'referer',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
'user-agent',
|
|
'via',
|
|
]);
|
|
|
|
let livewireListenerRegistered = false;
|
|
|
|
document.addEventListener('DOMContentLoaded', initializeXhrRedirect);
|
|
document.addEventListener('click', handleDocumentClick);
|
|
document.addEventListener('input', handleDocumentInput);
|
|
document.addEventListener('submit', handleDocumentSubmit);
|
|
document.addEventListener('keydown', handleDocumentKeydown);
|
|
document.addEventListener('livewire:init', initializeXhrRedirect);
|
|
|
|
function initializeXhrRedirect() {
|
|
hydrateSettings();
|
|
|
|
if (livewireListenerRegistered || ! window.Livewire) {
|
|
return;
|
|
}
|
|
|
|
livewireListenerRegistered = true;
|
|
window.Livewire.on('xhr-request-available', handleAvailableRequest);
|
|
window.Livewire.hook('morphed', hydrateSettings);
|
|
}
|
|
|
|
function hydrateSettings() {
|
|
document.querySelectorAll('[data-xhr-redirect-settings]').forEach((settings) => {
|
|
const endpointId = endpointIdFor(settings);
|
|
|
|
if (! endpointId) {
|
|
return;
|
|
}
|
|
|
|
const form = settings.querySelector('[data-xhr-redirect-config-form]');
|
|
const targetInput = settings.querySelector('[data-xhr-redirect-target]');
|
|
const enabledInput = settings.querySelector('[data-xhr-redirect-enabled]');
|
|
|
|
if (! form || ! targetInput || ! enabledInput) {
|
|
return;
|
|
}
|
|
|
|
const configuration = readConfiguration(endpointId);
|
|
|
|
if (form.dataset.xhrRedirectDirty !== 'true') {
|
|
targetInput.value = configuration.target;
|
|
}
|
|
|
|
enabledInput.checked = configuration.enabled;
|
|
updateSettingsStatus(settings, configuration);
|
|
});
|
|
}
|
|
|
|
function handleDocumentClick(event) {
|
|
const element = event.target instanceof Element ? event.target : null;
|
|
|
|
if (! element) {
|
|
return;
|
|
}
|
|
|
|
const openButton = element.closest('[data-xhr-redirect-open]');
|
|
|
|
if (openButton) {
|
|
const modal = openButton.closest('[data-xhr-redirect-endpoint]')?.querySelector('[data-xhr-redirect-modal]');
|
|
|
|
if (! modal) {
|
|
return;
|
|
}
|
|
|
|
modal.xhrRequestPayload = readButtonPayload(openButton);
|
|
openModal(modal);
|
|
|
|
return;
|
|
}
|
|
|
|
const closeButton = element.closest('[data-xhr-redirect-close]');
|
|
|
|
if (closeButton) {
|
|
closeModal(closeButton.closest('[data-xhr-redirect-modal]'));
|
|
}
|
|
}
|
|
|
|
function handleDocumentInput(event) {
|
|
const element = event.target instanceof HTMLInputElement ? event.target : null;
|
|
const form = element?.closest('[data-xhr-redirect-config-form]');
|
|
|
|
if (form) {
|
|
form.dataset.xhrRedirectDirty = 'true';
|
|
}
|
|
}
|
|
|
|
function handleDocumentSubmit(event) {
|
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
|
|
|
if (! form) {
|
|
return;
|
|
}
|
|
|
|
const configurationForm = form.closest('[data-xhr-redirect-config-form]');
|
|
|
|
if (configurationForm) {
|
|
event.preventDefault();
|
|
saveConfiguration(configurationForm);
|
|
|
|
return;
|
|
}
|
|
|
|
const sendForm = form.closest('[data-xhr-redirect-send-form]');
|
|
|
|
if (sendForm) {
|
|
event.preventDefault();
|
|
sendSelectedRequest(sendForm);
|
|
}
|
|
}
|
|
|
|
function handleDocumentKeydown(event) {
|
|
if (event.key !== 'Escape') {
|
|
return;
|
|
}
|
|
|
|
const modal = document.querySelector('[data-xhr-redirect-modal]:not([hidden])');
|
|
|
|
if (modal) {
|
|
closeModal(modal);
|
|
}
|
|
}
|
|
|
|
function saveConfiguration(form) {
|
|
const settings = form.closest('[data-xhr-redirect-settings]');
|
|
const endpointId = endpointIdFor(settings);
|
|
const targetInput = form.querySelector('[data-xhr-redirect-target]');
|
|
const enabledInput = form.querySelector('[data-xhr-redirect-enabled]');
|
|
const target = targetInput?.value.trim() ?? '';
|
|
const enabled = enabledInput?.checked ?? false;
|
|
|
|
if (! endpointId || ! targetInput || ! enabledInput) {
|
|
return;
|
|
}
|
|
|
|
if (enabled && ! isHttpUrl(target)) {
|
|
const error = new Error('Enter a valid http:// or https:// target URL.');
|
|
|
|
console.error('[Webhook Inspector] XHR redirect configuration failed.', {
|
|
endpointId,
|
|
target,
|
|
error: error.message,
|
|
});
|
|
updateFeedback(settings, error.message, 'error');
|
|
|
|
return;
|
|
}
|
|
|
|
const configuration = { enabled, target };
|
|
writeConfiguration(endpointId, configuration);
|
|
form.dataset.xhrRedirectDirty = 'false';
|
|
updateSettingsStatus(settings, configuration);
|
|
updateFeedback(
|
|
settings,
|
|
enabled ? 'XHR redirect is active for new requests in this tab.' : 'XHR redirect disabled.',
|
|
enabled ? 'success' : 'neutral',
|
|
);
|
|
}
|
|
|
|
async function handleAvailableRequest({ endpointId, requestId }) {
|
|
if (! endpointId || ! requestId) {
|
|
return;
|
|
}
|
|
|
|
const configuration = readConfiguration(endpointId);
|
|
|
|
if (! configuration.enabled || ! isHttpUrl(configuration.target)) {
|
|
return;
|
|
}
|
|
|
|
const requestKey = `${endpointId}:${requestId}`;
|
|
|
|
if (forwardedRequestIds.has(requestKey)) {
|
|
return;
|
|
}
|
|
|
|
forwardedRequestIds.add(requestKey);
|
|
|
|
const root = findEndpointRoot(endpointId);
|
|
let wireElement = root;
|
|
|
|
while (wireElement && ! wireElement.hasAttribute('wire:id')) {
|
|
wireElement = wireElement.parentElement;
|
|
}
|
|
|
|
const wireId = wireElement?.getAttribute('wire:id');
|
|
const component = wireId ? window.Livewire?.find(wireId) : null;
|
|
|
|
if (! component) {
|
|
logXhrError(
|
|
{ endpointId, requestId },
|
|
configuration.target,
|
|
new Error('The Livewire component is not available to load the request payload.'),
|
|
'automatic',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = await component.xhrRequestPayload(requestId);
|
|
const result = await sendRequest(payload, configuration.target);
|
|
const settings = root?.querySelector('[data-xhr-redirect-settings]');
|
|
|
|
logXhrResult(payload, configuration.target, result, 'automatic');
|
|
|
|
if (settings) {
|
|
updateFeedback(settings, `Forwarded ${payload.method} request (${result.status}).`, 'success');
|
|
}
|
|
} catch (error) {
|
|
const settings = root?.querySelector('[data-xhr-redirect-settings]');
|
|
|
|
logXhrError({ endpointId, requestId }, configuration.target, error, 'automatic');
|
|
|
|
if (settings) {
|
|
updateFeedback(settings, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendSelectedRequest(form) {
|
|
const modal = form.closest('[data-xhr-redirect-modal]');
|
|
const targetInput = form.querySelector('[data-xhr-redirect-target]');
|
|
const sendButton = form.querySelector('[data-xhr-redirect-send]');
|
|
const statusElement = form.querySelector('[data-xhr-redirect-status]');
|
|
const target = targetInput?.value.trim() ?? '';
|
|
const payload = modal?.xhrRequestPayload;
|
|
|
|
if (! modal || ! targetInput || ! sendButton || ! statusElement || ! payload) {
|
|
return;
|
|
}
|
|
|
|
if (! isHttpUrl(target)) {
|
|
const error = new Error('Enter a valid http:// or https:// target URL.');
|
|
|
|
logXhrError(payload, target, error, 'manual');
|
|
updateStatus(statusElement, error.message, 'error');
|
|
targetInput.focus();
|
|
|
|
return;
|
|
}
|
|
|
|
sendButton.disabled = true;
|
|
updateStatus(statusElement, `Sending ${payload.method} request…`, 'neutral');
|
|
|
|
sendRequest(payload, target)
|
|
.then((result) => {
|
|
showResponse(modal, result);
|
|
logXhrResult(payload, target, result, 'manual');
|
|
updateStatus(statusElement, `Request completed with ${result.status} ${result.statusText}.`, result.status >= 200 && result.status < 400 ? 'success' : 'error');
|
|
})
|
|
.catch((error) => {
|
|
hideResponse(modal);
|
|
logXhrError(payload, target, error, 'manual');
|
|
updateStatus(statusElement, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error');
|
|
})
|
|
.finally(() => {
|
|
sendButton.disabled = false;
|
|
});
|
|
}
|
|
|
|
function sendRequest(payload, target) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
const targetUrl = appendRequestQuery(target, payload.requestUri);
|
|
|
|
try {
|
|
xhr.open(payload.method, targetUrl, true);
|
|
xhr.timeout = 30_000;
|
|
|
|
Object.entries(payload.headers ?? {}).forEach(([name, values]) => {
|
|
if (isForbiddenRequestHeader(name)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
xhr.setRequestHeader(name, Array.isArray(values) ? values.join(', ') : String(values));
|
|
} catch {
|
|
// Browsers reject a few additional headers depending on their context.
|
|
}
|
|
});
|
|
} catch {
|
|
reject(new Error('The target URL or HTTP method is not supported by the browser.'));
|
|
|
|
return;
|
|
}
|
|
|
|
xhr.onload = () => resolve({
|
|
status: xhr.status,
|
|
statusText: xhr.statusText || 'Unknown status',
|
|
headers: xhr.getAllResponseHeaders().trim(),
|
|
body: xhr.responseText,
|
|
});
|
|
xhr.onerror = () => reject(new Error('The browser could not reach the target. Check its CORS headers and whether it is running.'));
|
|
xhr.ontimeout = () => reject(new Error('The target did not respond within 30 seconds.'));
|
|
xhr.onabort = () => reject(new Error('The XHR request was aborted.'));
|
|
|
|
try {
|
|
const body = decodeBase64ToBytes(payload.body ?? '');
|
|
const method = payload.method.toUpperCase();
|
|
|
|
xhr.send(['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? null : body);
|
|
} catch {
|
|
reject(new Error('The captured request body could not be decoded.'));
|
|
}
|
|
});
|
|
}
|
|
|
|
function logXhrResult(payload, target, result, mode) {
|
|
const details = {
|
|
mode,
|
|
endpointId: payload?.endpointId ?? null,
|
|
requestId: payload?.requestId ?? null,
|
|
method: payload?.method ?? null,
|
|
target: appendRequestQuery(target, payload?.requestUri),
|
|
status: result.status,
|
|
statusText: result.statusText,
|
|
};
|
|
|
|
if (result.status >= 400 || result.status === 0) {
|
|
console.error('[Webhook Inspector] XHR forwarding failed.', details);
|
|
|
|
return;
|
|
}
|
|
|
|
console.info('[Webhook Inspector] XHR forwarding succeeded.', details);
|
|
}
|
|
|
|
function logXhrError(payload, target, error, mode) {
|
|
console.error('[Webhook Inspector] XHR forwarding failed.', {
|
|
mode,
|
|
endpointId: payload?.endpointId ?? null,
|
|
requestId: payload?.requestId ?? null,
|
|
method: payload?.method ?? null,
|
|
target: appendRequestQuery(target, payload?.requestUri),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
function readButtonPayload(button) {
|
|
try {
|
|
return {
|
|
endpointId: button.dataset.xhrRequestEndpoint,
|
|
requestId: button.dataset.xhrRequestId,
|
|
method: button.dataset.xhrRequestMethod,
|
|
requestUri: button.dataset.xhrRequestUri,
|
|
headers: JSON.parse(decodeBase64ToString(button.dataset.xhrRequestHeaders ?? 'e30=')),
|
|
body: button.dataset.xhrRequestBody ?? '',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function appendRequestQuery(target, requestUri) {
|
|
const queryStart = String(requestUri ?? '').indexOf('?');
|
|
|
|
if (queryStart === -1) {
|
|
return target;
|
|
}
|
|
|
|
const query = String(requestUri).slice(queryStart + 1);
|
|
|
|
if (query === '') {
|
|
return target;
|
|
}
|
|
|
|
const hashStart = target.indexOf('#');
|
|
const hash = hashStart === -1 ? '' : target.slice(hashStart);
|
|
const targetWithoutHash = hashStart === -1 ? target : target.slice(0, hashStart);
|
|
const separator = targetWithoutHash.includes('?')
|
|
? (targetWithoutHash.endsWith('?') || targetWithoutHash.endsWith('&') ? '' : '&')
|
|
: '?';
|
|
|
|
return `${targetWithoutHash}${separator}${query}${hash}`;
|
|
}
|
|
|
|
function isForbiddenRequestHeader(name) {
|
|
const normalizedName = name.toLowerCase();
|
|
|
|
return forbiddenRequestHeaders.has(normalizedName)
|
|
|| normalizedName.startsWith('proxy-')
|
|
|| normalizedName.startsWith('sec-');
|
|
}
|
|
|
|
function isHttpUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
|
|
return ['http:', 'https:'].includes(url.protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readConfiguration(endpointId) {
|
|
const fallback = inMemoryConfigurations.get(endpointId);
|
|
|
|
try {
|
|
const stored = window.sessionStorage.getItem(storagePrefix + endpointId);
|
|
|
|
if (stored) {
|
|
const configuration = JSON.parse(stored);
|
|
|
|
if (typeof configuration.enabled === 'boolean' && typeof configuration.target === 'string') {
|
|
return configuration;
|
|
}
|
|
}
|
|
} catch {
|
|
// Session storage may be unavailable in privacy-restricted browsers.
|
|
}
|
|
|
|
return fallback ?? { enabled: false, target: '' };
|
|
}
|
|
|
|
function writeConfiguration(endpointId, configuration) {
|
|
inMemoryConfigurations.set(endpointId, configuration);
|
|
|
|
try {
|
|
window.sessionStorage.setItem(storagePrefix + endpointId, JSON.stringify(configuration));
|
|
} catch {
|
|
// Keep the in-memory configuration for the current page when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
function updateSettingsStatus(settings, configuration) {
|
|
const activeBadge = settings?.querySelector('[data-xhr-redirect-active]');
|
|
|
|
if (activeBadge) {
|
|
activeBadge.hidden = ! configuration.enabled;
|
|
}
|
|
}
|
|
|
|
function updateFeedback(settings, message, tone) {
|
|
const feedback = settings?.querySelector('[data-xhr-redirect-feedback]');
|
|
|
|
if (! feedback) {
|
|
return;
|
|
}
|
|
|
|
feedback.textContent = message;
|
|
feedback.classList.remove('text-slate-500', 'text-emerald-300', 'text-rose-300');
|
|
feedback.classList.add({ success: 'text-emerald-300', error: 'text-rose-300', neutral: 'text-slate-500' }[tone] ?? 'text-slate-500');
|
|
}
|
|
|
|
function updateStatus(statusElement, message, tone) {
|
|
statusElement.textContent = message;
|
|
statusElement.classList.remove('text-slate-400', 'text-cyan-200', 'text-emerald-200', 'text-rose-200');
|
|
statusElement.classList.add({ success: 'text-emerald-200', error: 'text-rose-200', neutral: 'text-slate-400' }[tone] ?? 'text-slate-400');
|
|
}
|
|
|
|
function showResponse(modal, result) {
|
|
const response = modal.querySelector('[data-xhr-redirect-response]');
|
|
const status = modal.querySelector('[data-xhr-response-status]');
|
|
const headers = modal.querySelector('[data-xhr-response-headers]');
|
|
const body = modal.querySelector('[data-xhr-response-body]');
|
|
|
|
if (! response || ! status || ! headers || ! body) {
|
|
return;
|
|
}
|
|
|
|
response.hidden = false;
|
|
status.textContent = `${result.status} ${result.statusText}`;
|
|
headers.textContent = limitText(result.headers || '(No response headers exposed by CORS.)', 8_000);
|
|
body.textContent = limitText(result.body || '(Empty response body.)', 32_000);
|
|
}
|
|
|
|
function hideResponse(modal) {
|
|
const response = modal.querySelector('[data-xhr-redirect-response]');
|
|
|
|
if (response) {
|
|
response.hidden = true;
|
|
}
|
|
}
|
|
|
|
function openModal(modal) {
|
|
const targetInput = modal.querySelector('[data-xhr-redirect-target]');
|
|
const endpointId = endpointIdFor(modal);
|
|
const configuration = endpointId ? readConfiguration(endpointId) : { enabled: false, target: '' };
|
|
|
|
if (targetInput && ! targetInput.value) {
|
|
targetInput.value = configuration.target;
|
|
}
|
|
|
|
modal.hidden = false;
|
|
hideResponse(modal);
|
|
updateStatus(modal.querySelector('[data-xhr-redirect-status]'), 'Ready to send.', 'neutral');
|
|
targetInput?.focus();
|
|
}
|
|
|
|
function closeModal(modal) {
|
|
if (modal) {
|
|
modal.hidden = true;
|
|
}
|
|
}
|
|
|
|
function findEndpointRoot(endpointId) {
|
|
return Array.from(document.querySelectorAll('[data-xhr-redirect-endpoint]'))
|
|
.find((root) => root.dataset.xhrRedirectEndpoint === endpointId) ?? null;
|
|
}
|
|
|
|
function endpointIdFor(element) {
|
|
return element?.closest('[data-xhr-redirect-endpoint]')?.dataset.xhrRedirectEndpoint ?? null;
|
|
}
|
|
|
|
function decodeBase64ToBytes(value) {
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
for (let index = 0; index < binary.length; index++) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
function decodeBase64ToString(value) {
|
|
return new TextDecoder().decode(decodeBase64ToBytes(value));
|
|
}
|
|
|
|
function limitText(value, limit) {
|
|
return value.length > limit ? `${value.slice(0, limit)}\n\n[Response truncated]` : value;
|
|
}
|