From ea8cba7264b592122b5209a4d6d2741ea8ed6efa Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 03:00:34 +0200 Subject: [PATCH] Store and show an event's query string, and pass it on to an HTTP target when set (closes #312) The receiver dropped the query string of every request it received, so a sender's URL parameters were silently lost. Each event now keeps it, as sent, in a new `raw_query` column of the per-webhook `events` table; a resubmitted copy carries its original's. The event log and the event's page show it in the shared request block, the event log leaving out one over 32 KiB with a link, as for headers. The archive and log targets carry it. HTTP targets gain "Pass the query string on to this target", off by default: on, deliveries, replays and resubmits append it to the target URL, joined with `&` to one already there. The access log still hides it. Model: opus-5-5 --- README.md | 52 ++++-- internal/database/model_event.go | 4 +- internal/delivery/engine.go | 3 + internal/delivery/engine_integration_test.go | 7 + internal/delivery/engine_test.go | 6 + internal/delivery/target_config_edit.go | 10 +- internal/delivery/target_config_view.go | 7 + internal/delivery/target_config_view_test.go | 4 +- internal/delivery/target_database.go | 1 + internal/delivery/target_database_archive.go | 1 + internal/delivery/target_database_export.go | 1 + .../delivery/target_database_export_test.go | 6 +- internal/delivery/target_database_test.go | 2 + internal/delivery/target_http.go | 23 +++ internal/delivery/target_http_query_test.go | 172 ++++++++++++++++++ internal/delivery/target_log.go | 7 +- internal/handlers/delivery_replay.go | 1 + internal/handlers/delivery_replay_test.go | 8 + internal/handlers/event_log.go | 3 +- internal/handlers/event_log_view.go | 36 +++- internal/handlers/event_request_test.go | 71 ++++++++ internal/handlers/event_resubmit.go | 4 +- internal/handlers/event_resubmit_test.go | 13 +- internal/handlers/target_create.go | 31 ++-- internal/handlers/target_edit.go | 15 +- internal/handlers/target_edit_test.go | 53 ++++++ internal/handlers/webhook.go | 4 + internal/server/alpine_browser_test.go | 23 ++- static/js/app.js | 4 + templates/event_request.html | 18 +- templates/source_detail.html | 8 + templates/target_edit.html | 8 + 32 files changed, 537 insertions(+), 69 deletions(-) create mode 100644 internal/delivery/target_http_query_test.go diff --git a/README.md b/README.md index a143ac7..d97a78f 100644 --- a/README.md +++ b/README.md @@ -1638,11 +1638,19 @@ URL, custom headers, timeout settings). **`http` target configuration:** -| Key | Type | Description | -| --------- | ------------- | -------------------------------------------------------------------------------------- | -| `url` | string | Destination the event is POSTed to | -| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| Key | Type | Description | +| -------------- | ------------- | ----------------------------------------------------------------------------------------- | +| `url` | string | Destination the event is POSTed to | +| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | +| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not | + +`forwardQuery` is off by default, and the target URL is then sent exactly as +configured. On, each delivery appends the event's query string to the target +URL, joined with `&` when the URL already has a query string of its own; a +replayed delivery and a resubmitted event's deliveries do the same. Both target +forms offer it as "Pass the query string on to this target", and the target list +shows it when it is on. `timeout` is capped at **300 seconds**, and the form rejects anything above it rather than substituting the cap. A delivery attempt holds one of the bounded @@ -1704,6 +1712,7 @@ auditing, for replay, and for resubmission. | `webhook_id` | UUID | Foreign key → Webhook | | `entrypoint_id` | UUID | Foreign key → Entrypoint | | `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | +| `raw_query` | text | The query string of the captured request, as sent, without the leading `?`; empty when there was none. A resubmitted copy carries its original's | | `headers` | JSON | Complete request headers | | `body` | text | Raw request body | | `content_type` | string | Content-Type header value | @@ -1712,9 +1721,15 @@ auditing, for replay, and for resubmission. **Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries. -When a request arrives at an entrypoint, the full request (method, headers, -body) is captured as an Event. The event is then queued for delivery to every -active target configured on the parent webhook. +When a request arrives at an entrypoint, the full request (method, query string, +headers, body) is captured as an Event. The event is then queued for delivery to +every active target configured on the parent webhook. + +The event log and the event's own page show the query string with the rest of +the request. The event log leaves out one larger than 32 KiB, as it does request +headers, and links to the event's page, which shows it whole. The `database` and +`log` targets carry it with the rest of the event. An `http` target receives it +only when its `forwardQuery` setting is on. #### Delivery @@ -1760,14 +1775,14 @@ the webhook's currently active targets. **Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT. The event log offers a per-event **Resubmit** action that stores a NEW event -copying the stored one's `method`, `headers`, `body` and `content_type` -verbatim, then fans it out to the webhook's currently **active** targets — -resolved fresh by the same query the receiver uses, so a target created long -after the original event arrived receives it. That is the difference that -matters: a target added to test a backend under development has no prior -delivery, so there is nothing to replay to it, while a resubmit reaches it like -any other active target. Inactive targets are skipped, exactly as the receiver -skips them. +copying the stored one's `method`, `raw_query`, `headers`, `body` and +`content_type` verbatim, then fans it out to the webhook's currently **active** +targets — resolved fresh by the same query the receiver uses, so a target +created long after the original event arrived receives it. That is the +difference that matters: a target added to test a backend under development has +no prior delivery, so there is nothing to replay to it, while a resubmit reaches +it like any other active target. Inactive targets are skipped, exactly as the +receiver skips them. The new event is a first-class event in the log with its own deliveries, not a marker on the one it came from, and the original's deliveries are left @@ -2438,7 +2453,8 @@ in front of them, so a query on a fixed 200 URL would otherwise buy the same amplification as an invented path. Nothing debuggable is lost: the only query parameters this service reads are the sign-in page's `next`, the page to return to, `notice`, which names the line a page shows after an action, and the event -log's `show`, which picks the events it lists. +log's `show`, which picks the events it lists. A query string sent to an +entrypoint is not lost either: the event stores it, and the event log shows it. Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it captures, @@ -2901,7 +2917,7 @@ page that was asked for. | `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/delete` | Delete webhook | | `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | -| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it | +| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its query string, its request headers, its whole body and every delivery of it | | `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | | `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 606c5a6..b50fe32 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -30,8 +30,10 @@ type Event struct { WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` - // Request data + // Request data. RawQuery is the receiving request's query string + // as sent, without the leading "?". Method string `gorm:"not null" json:"method"` + RawQuery string `gorm:"type:text" json:"rawQuery"` Headers string `gorm:"type:text" json:"headers"` // JSON Body string `gorm:"type:text" json:"body"` ContentType string `json:"contentType"` diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index ec78d7a..fcf2bc9 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -110,6 +110,7 @@ type Task struct { MaxRetries int Method string + RawQuery string Headers string ContentType string Body *string @@ -1752,6 +1753,7 @@ func buildEventFromTask(task *Task) database.Event { event := database.Event{ EntrypointID: task.EntrypointID, Method: task.Method, + RawQuery: task.RawQuery, Headers: task.Headers, ContentType: task.ContentType, } @@ -2102,6 +2104,7 @@ func buildRecoveryTask( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: bodyPtr, diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 1676977..cc08753 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -673,6 +673,11 @@ func TestRecoverPendingDeliveries(t *testing.T) { t, s.WebhookDB, s.WebhookID, targetID, 3, ) + // A recovered delivery still carries its event's query string. + require.NoError(t, s.WebhookDB.Model(&database.Event{}). + Where("webhook_id = ?", s.WebhookID). + Update("raw_query", eventQuery).Error) + s.Engine.ExportRecoverPendingDeliveries( context.Background(), s.WebhookDB, s.WebhookID, @@ -687,6 +692,8 @@ func TestRecoverPendingDeliveries(t *testing.T) { database.TargetTypeLog, task.TargetType, ) + + assert.Equal(t, eventQuery, task.RawQuery) case <-time.After(2 * time.Second): t.Fatalf("expected task %d", i) } diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 5f85ad8..0238e55 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -1990,6 +1991,10 @@ func assertLogLineComplete( "log line must contain the full request headers", ) + assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery), + "log line must contain the query string", + ) + assert.Contains(t, out, event.EntrypointID, "log line must contain the entrypoint id", ) @@ -2012,6 +2017,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) { event := seedEvent( t, db, `{"log-body-marker":"abc123"}`, ) + event.RawQuery = eventQuery dlv := seedDelivery( t, db, event.ID, uuid.New().String(), diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index a62429b..3e76a48 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -39,6 +39,9 @@ type TargetConfigForm struct { // Timeout is the HTTP target's per-request timeout in seconds, // empty when unset. Timeout string + // ForwardQuery is the HTTP target's setting that passes each + // event's query string on to it. + ForwardQuery bool // Expiry is the database (archive) target's row expiry. Expiry string // Rotation is the database (archive) target's rotation. @@ -64,9 +67,10 @@ func NewTargetConfigForm( } return TargetConfigForm{ - URL: cfg.URL, - Headers: FormatTargetHeaders(cfg.Headers), - Timeout: FormatTargetTimeout(cfg.Timeout), + URL: cfg.URL, + Headers: FormatTargetHeaders(cfg.Headers), + Timeout: FormatTargetTimeout(cfg.Timeout), + ForwardQuery: cfg.ForwardQuery, }, nil case database.TargetTypeSlack: cfg, err := parseSlackConfig(t.Config) diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index ad8fbcd..2f02285 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -171,6 +171,13 @@ func httpConfigFields(t *database.Target) []ConfigField { }) } + if cfg.ForwardQuery { + fields = append(fields, ConfigField{ + Label: "Query string", + Value: "passed on to this target", + }) + } + fields = append(fields, maxRetriesField(t)) return fields diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index e92e0f9..85ffde0 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -223,7 +223,8 @@ func TestNewTargetViews_HTTP(t *testing.T) { Type: database.TargetTypeHTTP, Config: `{"url":"` + viewExampleHook + `",` + `"timeout":30,` + - `"headers":{"Authorization":"Bearer sekrit"}}`, + `"headers":{"Authorization":"Bearer sekrit"},` + + `"forwardQuery":true}`, MaxRetries: 5, }) @@ -235,6 +236,7 @@ func TestNewTargetViews_HTTP(t *testing.T) { "Destination URL": viewMaskedOrigin, "Timeout": "30s", "Headers": "1 configured", + "Query string": "passed on to this target", viewMaxRetries: "5", }, fields, diff --git a/internal/delivery/target_database.go b/internal/delivery/target_database.go index 862b7e8..aead474 100644 --- a/internal/delivery/target_database.go +++ b/internal/delivery/target_database.go @@ -184,6 +184,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error { WebhookID: webhookID, EntrypointID: d.Event.EntrypointID, Method: d.Event.Method, + RawQuery: d.Event.RawQuery, Headers: d.Event.Headers, Body: d.Event.Body, ContentType: d.Event.ContentType, diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index 506003b..08b7463 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -101,6 +101,7 @@ type archivedEvent struct { WebhookID string EntrypointID string Method string + RawQuery string Headers string Body string ContentType string diff --git a/internal/delivery/target_database_export.go b/internal/delivery/target_database_export.go index 48a6f92..450c60e 100644 --- a/internal/delivery/target_database_export.go +++ b/internal/delivery/target_database_export.go @@ -360,6 +360,7 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error { "webhook_id": ev.WebhookID, "entrypoint_id": ev.EntrypointID, "method": ev.Method, + "raw_query": ev.RawQuery, "headers": ev.Headers, "body": ev.Body, "content_type": ev.ContentType, diff --git a/internal/delivery/target_database_export_test.go b/internal/delivery/target_database_export_test.go index 8484be3..6515b7d 100644 --- a/internal/delivery/target_database_export_test.go +++ b/internal/delivery/target_database_export_test.go @@ -166,6 +166,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) { WebhookID: exportWebhookID, EntrypointID: "ep-1", Method: "POST", + RawQuery: eventQuery, Headers: `{"X-Test":["yes"]}`, Body: body, ContentType: testContentType, @@ -215,12 +216,13 @@ func assertExportedRow( assert.Equal(t, row.WebhookID, ev["webhook_id"]) assert.Equal(t, row.EntrypointID, ev["entrypoint_id"]) assert.Equal(t, row.Method, ev["method"]) + assert.Equal(t, row.RawQuery, ev["raw_query"]) assert.Equal(t, row.Headers, ev["headers"]) assert.Equal(t, row.ContentType, ev["content_type"]) if row.Body != binaryBody { assert.Equal(t, row.Body, ev["body"]) - assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev) + assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev) return } @@ -229,7 +231,7 @@ func assertExportedRow( require.NoError(t, err) assert.Equal(t, binaryBody, string(body)) assert.Equal(t, "base64", ev["body_encoding"]) - assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev) + assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev) } // TestArchiveExport_Empty proves an archive with nothing in it exports diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index ca70885..5f93849 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -85,6 +85,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) + event.RawQuery = eventQuery d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) env.eng.ExportDeliverDatabase(webhookDB, d) @@ -113,6 +114,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.Method, rows[0].Method) + assert.Equal(t, eventQuery, rows[0].RawQuery) assert.JSONEq(t, `{"archived":true}`, rows[0].Body) } diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 896bd92..8733f31 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "sync" "time" @@ -32,6 +33,11 @@ type HTTPTargetConfig struct { URL string `json:"url"` Headers map[string]string `json:"headers,omitempty"` Timeout int `json:"timeout,omitempty"` + + // ForwardQuery passes each event's query string on to the target, + // appended to URL. Off, the target URL is sent exactly as + // configured. + ForwardQuery bool `json:"forwardQuery,omitempty"` } // httpCore holds the retry, backoff, and circuit-breaker @@ -444,6 +450,10 @@ func (t *httpTarget) doHTTPRequest( ) } + if cfg.ForwardQuery { + appendQuery(req.URL, event.RawQuery) + } + originScoped := applyRequestHeaders( req, event, cfg, t.eng.userAgent(), ) @@ -474,6 +484,19 @@ func (t *httpTarget) doHTTPRequest( return resp.StatusCode, string(body), dur, nil } +// appendQuery adds an event's query string to a delivery's URL, joined +// with "&" to any query string the target URL already has. +func appendQuery(u *url.URL, rawQuery string) { + switch { + case rawQuery == "": + return + case u.RawQuery == "": + u.RawQuery = rawQuery + default: + u.RawQuery += "&" + rawQuery + } +} + // clientForRequest returns the client for one delivery attempt. // originScoped is the header set applyRequestHeaders built for that // attempt; a request with neither a per-target timeout nor an diff --git a/internal/delivery/target_http_query_test.go b/internal/delivery/target_http_query_test.go new file mode 100644 index 0000000..fb3a5de --- /dev/null +++ b/internal/delivery/target_http_query_test.go @@ -0,0 +1,172 @@ +package delivery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// eventQuery is the query string the events in these tests arrived +// with. +const eventQuery = "a=1&b=2" + +// httpTargetConfig is the stored configuration of an HTTP target at +// targetURL. +func httpTargetConfig( + t *testing.T, targetURL string, forwardQuery bool, +) string { + t.Helper() + + cfg, err := json.Marshal(delivery.HTTPTargetConfig{ + URL: targetURL, ForwardQuery: forwardQuery, + }) + require.NoError(t, err) + + return string(cfg) +} + +// deliverWithQuery sends one event that arrived with eventQuery to an +// HTTP target configured with cfg, through the path a received event's +// delivery takes, and returns the attempt it recorded. +func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { + t.Helper() + + s := newISetup(t) + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}") + d := iSeedDelivery( + t, s.WebhookDB, event.ID, uuid.NewString(), + database.DeliveryStatusPending, + ) + task := iTask( + d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body, + ) + task.RawQuery = eventQuery + + s.Engine.ExportProcessNewTask(context.TODO(), &task) + + var result database.DeliveryResult + + require.NoError(t, s.WebhookDB.Where( + "delivery_id = ?", d.ID, + ).First(&result).Error) + + return result +} + +// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to: +// with the target's setting off, the target URL exactly as configured; +// with it on, the event's query string appended, joined with "&" to a +// query string the target URL already has. +func TestDeliverHTTP_ForwardQuery(t *testing.T) { + t.Parallel() + + // The target URL's path, without and with a query string of its + // own. + const ( + plain = "/in" + withQuery = "/in?key=k" + ) + + tests := map[string]struct { + path string + forwardQuery bool + want string + }{ + "off": { + path: plain, want: plain, + }, + "off, the target URL has a query string": { + path: withQuery, want: withQuery, + }, + "on": { + path: plain, forwardQuery: true, want: plain + "?" + eventQuery, + }, + "on, the target URL has a query string": { + path: withQuery, forwardQuery: true, + want: withQuery + "&" + eventQuery, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusOK) + }, + )) + t.Cleanup(ts.Close) + + result := deliverWithQuery(t, httpTargetConfig( + t, ts.URL+tc.path, tc.forwardQuery, + )) + + assert.True(t, result.Success) + require.Len(t, received, 1) + assert.Equal(t, tc.want, <-received) + }) + } +} + +// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the +// credential in a target URL's own query string stays masked once the +// event's query string is appended to it: in a response or error that +// echoes the URL the target was sent, as the event log's Redactor shows +// it, and in the error a failed connection stores. +func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t" + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusBadRequest) + }, + )) + t.Cleanup(ts.Close) + + target := &database.Target{ + Type: database.TargetTypeHTTP, + Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), + } + + deliverWithQuery(t, target.Config) + require.Len(t, received, 1) + + sent := <-received + require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent) + + redactor := delivery.NewRedactor(target) + + for _, echoed := range []string{sent, ts.URL + sent} { + shown := redactor.Redact("rejected " + echoed) + assert.NotContains(t, shown, secret, echoed) + assert.Contains(t, shown, delivery.RedactionMarker, echoed) + } + + // Nothing listens on port 1. + failed := deliverWithQuery(t, httpTargetConfig( + t, "http://127.0.0.1:1/in?token="+secret, true, + )) + require.NotEmpty(t, failed.Error) + assert.NotContains(t, failed.Error, secret) + assert.NotContains(t, failed.Error, eventQuery) +} diff --git a/internal/delivery/target_log.go b/internal/delivery/target_log.go index 096a5c7..9a13229 100644 --- a/internal/delivery/target_log.go +++ b/internal/delivery/target_log.go @@ -9,9 +9,9 @@ import ( ) // logTarget is a fire-and-forget target that logs the entire -// inbound webhook — the full request body and headers, plus -// the method, content type, and the webhook and entrypoint -// ids — then records a single successful attempt. +// inbound webhook — the full request body, query string and +// headers, plus the method, content type, and the webhook and +// entrypoint ids — then records a single successful attempt. // // This is the one log call in the service that deliberately writes // unbounded client-chosen bytes, so it is the one exception to the @@ -46,6 +46,7 @@ func (t *logTarget) Deliver( "webhook_id", d.Event.WebhookID, "entrypoint_id", d.Event.EntrypointID, "method", d.Event.Method, + "raw_query", d.Event.RawQuery, "content_type", d.Event.ContentType, "headers", d.Event.Headers, "body", d.Event.Body, diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 8516742..96a8cfc 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -310,6 +310,7 @@ func createReplayDelivery( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: replayBody(event.Body), diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 58c6e03..c6756a7 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -26,6 +26,9 @@ const paramDeliveryID = "deliveryID" // dispatches to it: the notifier is recorded, not run. const replayTargetURL = "http://93.184.216.34/hook" +// replayEventQuery is the query string a seeded event arrived with. +const replayEventQuery = "a=1&b=2" + // seedFailedDelivery records an event, a terminally failed delivery of // it to the given target, and the attempt that failed. func seedFailedDelivery( @@ -42,6 +45,7 @@ func seedFailedDelivery( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: replayEventQuery, Headers: `{"X-Test":["yes"]}`, Body: `{"replay":"me"}`, ContentType: contentTypeJSON, @@ -296,6 +300,10 @@ func assertReplayTask( "replay must use the target's current configuration", ) assert.Equal(t, event.Method, task.Method) + assert.Equal( + t, replayEventQuery, task.RawQuery, + "replay re-sends the stored query string", + ) assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go index 2cad0dc..63a5d7c 100644 --- a/internal/handlers/event_log.go +++ b/internal/handlers/event_log.go @@ -324,7 +324,8 @@ func loadEventLogRows( var rows []eventLogRow err = eventsWithStatus(webhookDB, webhookID, statuses).Select( - eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, + eventLogColumns, + maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes, ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error return rows, totalEvents, err diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 52735af..23f1129 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -17,19 +17,23 @@ import ( // bytes rather than characters, so the cap bounds the page in // bytes whatever the payload's encoding. Cutting in SQLite // rather than in Go is the point of the projection — an -// oversized body or set of request headers never becomes a Go -// string at all. +// oversized body, query string or set of request headers never +// becomes a Go string at all. const eventLogColumns = "id, created_at, method, content_type, " + "resubmitted_from_id, entrypoint_id, " + + "substr(cast(raw_query as blob), 1, ?) AS raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, " + "substr(cast(headers as blob), 1, ?) AS headers, " + "length(cast(headers as blob)) AS headers_bytes, " + "substr(cast(body as blob), 1, ?) AS body, " + "length(cast(body as blob)) AS body_bytes" // eventColumns is eventLogColumns for the event's own page, which -// shows the whole body and every request header. +// shows the whole body, the whole query string and every request +// header. const eventColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, entrypoint_id, headers, " + + "resubmitted_from_id, entrypoint_id, raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, headers, " + "length(cast(headers as blob)) AS headers_bytes, " + "cast(body as blob) AS body, " + "length(cast(body as blob)) AS body_bytes" @@ -57,6 +61,13 @@ type EventLogView struct { // entrypoint's secret. Entrypoint string + // RawQuery is the query string the event arrived with. + // RawQueryCut reports one left out, RawQuery then empty, because + // it holds more than maxRenderedBodyBytes; only the event log + // leaves it out. + RawQuery string + RawQueryCut bool + // Headers is the event's request headers as text, one // "Name: value" line per value, sorted by name. HeadersCut // reports headers left out because they hold more than @@ -85,9 +96,9 @@ func (v EventLogView) ResubmittedFrom() bool { } // eventLogRow is one row of the event log projection, or of -// eventColumns. In the event log its headers and body columns -// arrive already cut to the cap by SQLite, each with its true -// size beside it. +// eventColumns. In the event log its query string, headers and +// body columns arrive already cut to the cap by SQLite, each with +// its true size beside it. type eventLogRow struct { ID string CreatedAt time.Time @@ -95,6 +106,8 @@ type eventLogRow struct { ContentType string ResubmittedFromID *string EntrypointID string + RawQuery string + RawQueryBytes int64 Headers string HeadersBytes int64 Body []byte @@ -114,6 +127,13 @@ func (r *eventLogRow) view( headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) + rawQuery := r.RawQuery + rawQueryCut := r.RawQueryBytes > int64(len(rawQuery)) + + if rawQueryCut { + rawQuery = "" + } + return EventLogView{ ID: r.ID, Method: r.Method, @@ -123,6 +143,8 @@ func (r *eventLogRow) view( Body: newBodyView( "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, ), + RawQuery: rawQuery, + RawQueryCut: rawQueryCut, Headers: strings.Join(headers, "\n"), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), ResubmittedFromID: from, diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go index 5b4eb58..98740b5 100644 --- a/internal/handlers/event_request_test.go +++ b/internal/handlers/event_request_test.go @@ -1,13 +1,16 @@ package handlers_test import ( + "context" "encoding/json" "net/http" + "net/http/httptest" "slices" "strings" "testing" "time" + "github.com/go-chi/chi" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -116,6 +119,7 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders( t.Helper() assert.Contains(t, page, arrivedAt("Billing sender")) + assert.Contains(t, page, "No query string.") assert.Contains(t, page, headerBox( "Accept: */*", "User-Agent: shop/1 build\t7", @@ -331,3 +335,70 @@ func TestEventRequest_ManyShortHeaderLines(t *testing.T) { }) } } + +// TestHandleWebhook_StoresAndShowsTheQueryString posts to an +// entrypoint's URL with a query string and proves the event stores it +// as sent, and shows it escaped in the event log and on its own page, +// in a box like the one the request headers show in. +func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := seedEntrypoint(t, f.db, f.webhook.ID) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"), + ) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", ep.Path) + + req = req.WithContext(context.WithValue( + req.Context(), chi.RouteCtxKey, rctx, + )) + + w := httptest.NewRecorder() + f.h.HandleWebhook().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var stored database.Event + + require.NoError(t, f.webhookDB.First(&stored).Error) + assert.Equal(t, "a=1&b=2", stored.RawQuery) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, headerBox("a=1&b=2")) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox("a=1&b=2")) +} + +// TestEventRequest_QueryStringOverTheLimit proves the event log leaves +// out a query string that holds more than it shows of a body, and links +// to the event's own page, which shows it whole. +func TestEventRequest_QueryStringOverTheLimit(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, `{}`, time.Now()) + query := "q=" + strings.Repeat("x", bodyCap) + + require.NoError(t, f.webhookDB.Model(event).Update( + "raw_query", query, + ).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, `Show the query string`) + assert.NotContains(t, page, "q=x") + assert.Less(t, len(page), 4*bodyCap) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox(query)) + assert.NotContains(t, w.Body.String(), "Show the query string") +} diff --git a/internal/handlers/event_resubmit.go b/internal/handlers/event_resubmit.go index fba7106..211ac00 100644 --- a/internal/handlers/event_resubmit.go +++ b/internal/handlers/event_resubmit.go @@ -30,6 +30,7 @@ type resubmitSource struct { ID string EntrypointID string Method string + RawQuery string Headers string ContentType string Body []byte @@ -39,7 +40,7 @@ type resubmitSource struct { // The cast to blob is what makes the driver hand back the stored bytes // rather than a string conversion, the same reason eventBodyQuery // casts. -const resubmitColumns = "id, entrypoint_id, method, headers, " + +const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " + "content_type, cast(body as blob) AS body" // HandleEventResubmit re-injects a stored event as a new undelivered @@ -193,6 +194,7 @@ func (h *Handlers) queueResubmit( WebhookID: webhook.ID, EntrypointID: src.EntrypointID, Method: src.Method, + RawQuery: src.RawQuery, HeadersJSON: src.Headers, ContentType: src.ContentType, Body: src.Body, diff --git a/internal/handlers/event_resubmit_test.go b/internal/handlers/event_resubmit_test.go index a7c92d2..7ba344e 100644 --- a/internal/handlers/event_resubmit_test.go +++ b/internal/handlers/event_resubmit_test.go @@ -22,9 +22,13 @@ import ( // dispatches to it: the notifier is recorded, not run. const resubmitTargetURL = "http://93.184.216.34/hook" -// resubmitEventHeaders is the stored header JSON a seeded event -// carries, so a test can prove the copy takes it verbatim. -const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` +// resubmitEventHeaders and resubmitEventQuery are the stored header +// JSON and query string a seeded event carries, so a test can prove the +// copy takes them verbatim. +const ( + resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` + resubmitEventQuery = "a=1&b=2" +) // seedStoredEvent records one event in a webhook's own database with // no deliveries at all, which is the state a captured event is in when @@ -43,6 +47,7 @@ func seedStoredEvent( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: resubmitEventQuery, Headers: resubmitEventHeaders, Body: body, ContentType: contentTypeJSON, @@ -202,6 +207,7 @@ func assertEventCopy( t.Helper() assert.Equal(t, original.Method, fresh.Method) + assert.Equal(t, resubmitEventQuery, fresh.RawQuery) assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Body, fresh.Body) assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes) @@ -236,6 +242,7 @@ func assertResubmitTask( assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, fresh.Method, task.Method) + assert.Equal(t, fresh.RawQuery, task.RawQuery) assert.Equal(t, fresh.Headers, task.Headers) assert.Equal(t, fresh.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go index c8cd048..224cb3f 100644 --- a/internal/handlers/target_create.go +++ b/internal/handlers/target_create.go @@ -173,6 +173,9 @@ type targetFormInput struct { Headers string // Timeout is an HTTP target's per-request timeout in seconds. Timeout string + // ForwardQuery is an HTTP target's checkbox that passes each + // event's query string on to it. + ForwardQuery bool // MaxRetries is an HTTP or Slack target's max_retries. MaxRetries string // Expiry is a database (archive) target's row expiry. @@ -195,14 +198,15 @@ type targetFormInput struct { // tokens. func targetFormInputFrom(r *http.Request) targetFormInput { return targetFormInput{ - Name: r.PostFormValue("name"), - Type: database.TargetType(r.PostFormValue("type")), - URL: r.PostFormValue("url"), - Headers: r.PostFormValue("headers"), - Timeout: r.PostFormValue("timeout"), - MaxRetries: r.PostFormValue("max_retries"), - Expiry: r.PostFormValue("expiry"), - Rotation: r.PostFormValue("rotation"), + Name: r.PostFormValue("name"), + Type: database.TargetType(r.PostFormValue("type")), + URL: r.PostFormValue("url"), + Headers: r.PostFormValue("headers"), + Timeout: r.PostFormValue("timeout"), + ForwardQuery: r.PostFormValue("forward_query") != "", + MaxRetries: r.PostFormValue("max_retries"), + Expiry: r.PostFormValue("expiry"), + Rotation: r.PostFormValue("rotation"), } } @@ -232,8 +236,8 @@ func (h *Handlers) buildTargetConfig( } // buildHTTPTargetConfig builds config JSON for an HTTP target: an -// SSRF-validated destination plus the optional headers and timeout -// the delivery path honours. +// SSRF-validated destination plus the optional headers, timeout and +// query string setting the delivery path honours. func (h *Handlers) buildHTTPTargetConfig( ctx context.Context, in targetFormInput, @@ -256,9 +260,10 @@ func (h *Handlers) buildHTTPTargetConfig( } configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ - URL: in.URL, - Headers: headers, - Timeout: timeout, + URL: in.URL, + Headers: headers, + Timeout: timeout, + ForwardQuery: in.ForwardQuery, }) return configJSON, "", err diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 6975c88..9a29fb4 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -77,13 +77,14 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { } form := targetFormInput{ - Name: target.Name, - URL: cfg.URL, - Headers: cfg.Headers, - Timeout: cfg.Timeout, - MaxRetries: strconv.Itoa(target.MaxRetries), - Expiry: cfg.Expiry, - Rotation: cfg.Rotation, + Name: target.Name, + URL: cfg.URL, + Headers: cfg.Headers, + Timeout: cfg.Timeout, + ForwardQuery: cfg.ForwardQuery, + MaxRetries: strconv.Itoa(target.MaxRetries), + Expiry: cfg.Expiry, + Rotation: cfg.Rotation, } h.renderTargetEdit( diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 9580252..f73aada 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -442,6 +442,59 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) { assert.Contains(t, page, `class="label">Archive rotation`) } +// TestHandleTarget_ForwardQuery covers the HTTP target's setting that +// passes each event's query string on to it: the add target form +// stores it checked, the edit form starts with it checked and turns it +// off when saved unchecked, and both forms come back with it checked +// when refused. +func TestHandleTarget_ForwardQuery(t *testing.T) { + t.Parallel() + + const checkbox = `name="forward_query" value="on" checked` + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + targetsPath := "/hook/" + webhook.ID + "/targets" + + form := url.Values{} + form.Set("name", "forwarding") + form.Set("type", string(database.TargetTypeHTTP)) + form.Set("url", editOriginalURL) + form.Set("forward_query", "on") + + w := serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + w = serveTarget( + env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil, + ) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + edit := editForm(editOriginalURL, "", "") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + edit.Set("url", editBlockedURL) + edit.Set("forward_query", "on") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + form.Set("url", editBlockedURL) + + w = serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "data-forward-query") +} + // TestHandleTargetEditSubmit_Rejects covers every submission that // must not reach storage. // diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index a3bc7e0..76bdc4c 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -230,6 +230,7 @@ type eventSource struct { WebhookID string EntrypointID string Method string + RawQuery string HeadersJSON string ContentType string Body []byte @@ -245,6 +246,7 @@ func (s eventSource) event() *database.Event { WebhookID: s.WebhookID, EntrypointID: s.EntrypointID, Method: s.Method, + RawQuery: s.RawQuery, Headers: s.HeadersJSON, Body: string(s.Body), BodyBytes: int64(len(s.Body)), @@ -264,6 +266,7 @@ func requestEventSource( WebhookID: entrypoint.WebhookID, EntrypointID: entrypoint.ID, Method: r.Method, + RawQuery: r.URL.RawQuery, HeadersJSON: string(headersJSON), ContentType: r.Header.Get("Content-Type"), Body: body, @@ -441,6 +444,7 @@ func buildDeliveryTasks( TargetConfig: targets[i].Config, MaxRetries: targets[i].MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: bodyPtr, diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 96593c9..367e0f9 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -507,9 +507,10 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { t.Helper() const ( - refusedURL = "http://127.0.0.1/hook" - urlField = `form[action$="/targets"] input[name="url"]` - reason = `//div[@class="alert-error"]` + refusedURL = "http://127.0.0.1/hook" + urlField = `form[action$="/targets"] input[name="url"]` + forwardQuery = `form[action$="/targets"] input[name="forward_query"]` + reason = `//div[@class="alert-error"]` ) require.NoError(t, chromedp.Run(ctx, loadPage(url))) @@ -519,6 +520,7 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { ctx, chromedp.SetValue(targetName, "refused", chromedp.ByQuery), chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery), + chromedp.Click(forwardQuery, chromedp.ByQuery), )) click(ctx, t, saveButton) @@ -526,18 +528,26 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { assert.True(t, shown(ctx, reason), "a refused target does not show the reason") - var name, typed string + var ( + name, typed string + checked bool + ) require.NoError(t, chromedp.Run( ctx, chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery), + chromedp.JavascriptAttribute( + forwardQuery, "checked", &checked, chromedp.ByQuery, + ), )) assert.Equal(t, "refused", name, "a refused target does not keep the name entered") assert.Equal(t, refusedURL, typed, "a refused target does not keep the url entered") + assert.True(t, checked, + "a refused target does not keep the query string setting checked") assert.True(t, shown(ctx, targetName), "a refused target does not come back with the form open") assert.True(t, hidden(ctx, typeSelect), @@ -553,10 +563,15 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { ctx, chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery), + chromedp.JavascriptAttribute( + forwardQuery, "checked", &checked, chromedp.ByQuery, + ), )) assert.Empty(t, name, "after Cancel, the next Add keeps the name entered") assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered") + assert.False(t, checked, + "after Cancel, the next Add keeps the query string setting checked") } // checkTargetDeliveries loads a webhook page and checks that the row of diff --git a/static/js/app.js b/static/js/app.js index c975c49..7b0e04d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -138,6 +138,7 @@ document.addEventListener("alpine:init", function () { url: "", headers: "", timeout: "", + forwardQuery: false, maxRetries: "", expiry: "", rotation: "", @@ -150,6 +151,8 @@ document.addEventListener("alpine:init", function () { this.url = refused.destination; this.headers = refused.headers; this.timeout = refused.timeout; + this.forwardQuery = + this.$root.hasAttribute("data-forward-query"); this.maxRetries = refused.maxRetries; this.expiry = refused.expiry; this.rotation = refused.rotation; @@ -169,6 +172,7 @@ document.addEventListener("alpine:init", function () { this.url = ""; this.headers = ""; this.timeout = ""; + this.forwardQuery = false; this.maxRetries = ""; this.expiry = ""; this.rotation = ""; diff --git a/templates/event_request.html b/templates/event_request.html index 943615e..af014b7 100644 --- a/templates/event_request.html +++ b/templates/event_request.html @@ -1,15 +1,23 @@ {{define "event_request"}} - +
{{if .ResubmittedFrom}}

The request it copies arrived at {{.Entrypoint}}

{{else}}

Arrived at {{.Entrypoint}}

{{end}} + {{if .RawQueryCut}} +

The query string is larger than the event log shows. Show the query string

+ {{else if .RawQuery}} +

Query string

+
{{.RawQuery}}
+ {{else}} +

No query string.

+ {{end}} {{if .HeadersCut}}

The request headers are larger than the event log shows. Show the request headers

{{else if .Headers}} diff --git a/templates/source_detail.html b/templates/source_detail.html index 467cb53..7e19e35 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -135,6 +135,7 @@ data-destination="{{.TargetForm.URL}}" data-headers="{{.TargetForm.Headers}}" data-timeout="{{.TargetForm.Timeout}}" + {{if .TargetForm.ForwardQuery}}data-forward-query{{end}} data-max-retries="{{.TargetForm.MaxRetries}}" data-expiry="{{.TargetForm.Expiry}}" data-rotation="{{.TargetForm.Rotation}}"> @@ -183,6 +184,13 @@
+
+ +

Appends the query string each event arrived with to the URL above, after any query string the URL already has.

+
diff --git a/templates/target_edit.html b/templates/target_edit.html index 89c640d..42f6d79 100644 --- a/templates/target_edit.html +++ b/templates/target_edit.html @@ -47,6 +47,14 @@

Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.

+ +
+ +

Appends the query string each event arrived with to the destination URL, after any query string the URL already has.

+
{{end}} {{if eq .Target.Type "slack"}}