Add browser XHR forwarding

This commit is contained in:
2026-08-05 16:19:43 +02:00
parent f2757c9b1c
commit 06f99b0c17
5 changed files with 701 additions and 1 deletions
+1
View File
@@ -7,3 +7,4 @@
*/
import './echo';
import './xhr-redirect';
+549
View File
@@ -0,0 +1,549 @@
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;
}