Store and show an event's query string, and pass it on to an HTTP target when set (closes #312)
check / check (push) Successful in 4m45s

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
This commit was merged in pull request #501.
This commit is contained in:
2026-10-04 03:00:34 +02:00
parent a8fc0c5d32
commit ea8cba7264
32 changed files with 537 additions and 69 deletions
+3 -1
View File
@@ -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"`
+3
View File
@@ -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,
@@ -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)
}
+6
View File
@@ -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(),
+7 -3
View File
@@ -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)
+7
View File
@@ -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
+3 -1
View File
@@ -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,
+1
View File
@@ -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,
@@ -101,6 +101,7 @@ type archivedEvent struct {
WebhookID string
EntrypointID string
Method string
RawQuery string
Headers string
Body string
ContentType string
@@ -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,
@@ -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
@@ -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)
}
+23
View File
@@ -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
+172
View File
@@ -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)
}
+4 -3
View File
@@ -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,
+1
View File
@@ -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),
@@ -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)
+2 -1
View File
@@ -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
+29 -7
View File
@@ -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,
+71
View File
@@ -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&amp;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&amp;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, `<a href="/hook/`+f.webhook.ID+`/events/`+
event.ID+`" class="btn-small">Show the query string</a>`)
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")
}
+3 -1
View File
@@ -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,
+10 -3
View File
@@ -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)
+18 -13
View File
@@ -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
+8 -7
View File
@@ -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(
+53
View File
@@ -442,6 +442,59 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// 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.
//
+4
View File
@@ -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,
+19 -4
View File
@@ -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