diff --git a/app/Livewire/Inspector.php b/app/Livewire/Inspector.php index f47506e..5924a5f 100644 --- a/app/Livewire/Inspector.php +++ b/app/Livewire/Inspector.php @@ -75,6 +75,16 @@ class Inspector extends Component { if (($event['endpointId'] ?? null) === $this->endpointId) { $this->resetPage(); + + $requestId = $event['requestId'] ?? null; + + if (is_string($requestId) && $requestId !== '') { + $this->dispatch( + 'xhr-request-available', + endpointId: $this->endpointId, + requestId: $requestId, + ); + } } } @@ -100,6 +110,31 @@ class Inspector extends Component $this->selectedRequestId = null; } + /** + * @return array{ + * endpointId: string, + * requestId: string, + * method: string, + * requestUri: string, + * headers: array|string>, + * body: string, + * } + */ + public function xhrRequestPayload(string $requestId): array + { + $webhookRequest = $this->endpoint()->requests()->findOrFail($requestId); + Gate::authorize('view', $webhookRequest); + + return [ + 'endpointId' => (string) $this->endpointId, + 'requestId' => (string) $webhookRequest->getKey(), + 'method' => $webhookRequest->method, + 'requestUri' => $webhookRequest->request_uri, + 'headers' => $webhookRequest->headers ?? [], + 'body' => base64_encode((string) ($webhookRequest->body ?? '')), + ]; + } + public function deleteRequest(string $requestId, DeleteWebhookRequestAction $deleteWebhookRequest): void { $webhookRequest = $this->endpoint()->requests()->findOrFail($requestId); diff --git a/resources/js/app.js b/resources/js/app.js index c5ffd7d..54795b9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,3 +7,4 @@ */ import './echo'; +import './xhr-redirect'; diff --git a/resources/js/xhr-redirect.js b/resources/js/xhr-redirect.js new file mode 100644 index 0000000..8ed5f37 --- /dev/null +++ b/resources/js/xhr-redirect.js @@ -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; +} diff --git a/resources/views/livewire/inspector.blade.php b/resources/views/livewire/inspector.blade.php index a469640..ed885ac 100644 --- a/resources/views/livewire/inspector.blade.php +++ b/resources/views/livewire/inspector.blade.php @@ -1,4 +1,4 @@ -
+
@@ -93,6 +93,17 @@

{{ $selectedRequest->id }}

+
@@ -152,6 +163,53 @@
+ @if ($selectedRequest) + + @endif + @if ($showEndpointSettings)
+
+
+
+

XHR redirect

+

Forward new incoming requests from this browser tab to a local or remote endpoint. Forwarding runs in the browser and stops when this tab is closed.

+
+ +
+ +
+
+ + +
+ + +
+ +

+

The target should answer preflight requests and include Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers.

+
+

Send requests here

Any HTTP method is accepted.

diff --git a/tests/Feature/WebhookInspectorTest.php b/tests/Feature/WebhookInspectorTest.php index b2f6e37..ba74077 100644 --- a/tests/Feature/WebhookInspectorTest.php +++ b/tests/Feature/WebhookInspectorTest.php @@ -128,6 +128,38 @@ class WebhookInspectorTest extends TestCase ->assertSee('Save response'); } + public function test_selected_requests_expose_browser_xhr_forwarding_controls(): void + { + $endpoint = WebhookEndpoint::factory()->create(); + $webhookRequest = WebhookRequest::factory()->forEndpoint($endpoint)->create([ + 'method' => 'POST', + 'request_uri' => '/hook/test?source=browser', + 'headers' => ['content-type' => ['application/json'], 'x-request-id' => ['req-123']], + 'body' => '{"event":"created"}', + ]); + + $payload = new Inspector; + $payload->mount($endpoint); + + $this->assertSame($endpoint->getKey(), $payload->xhrRequestPayload($webhookRequest->getKey())['endpointId']); + $this->assertSame(base64_encode($webhookRequest->body), $payload->xhrRequestPayload($webhookRequest->getKey())['body']); + + Livewire::test(Inspector::class, ['endpoint' => $endpoint]) + ->call('selectRequest', $webhookRequest->getKey()) + ->assertSee('Send via XHR') + ->assertSee('data-xhr-redirect-modal', false) + ->assertSee('data-xhr-request-headers', false) + ->call('requestReceived', [ + 'endpointId' => (string) $endpoint->getKey(), + 'requestId' => (string) $webhookRequest->getKey(), + ]) + ->assertDispatched( + 'xhr-request-available', + endpointId: (string) $endpoint->getKey(), + requestId: (string) $webhookRequest->getKey(), + ); + } + public function test_request_body_is_escaped_in_the_inspector(): void { $endpoint = WebhookEndpoint::factory()->create();