All checks were successful
check / check (push) Successful in 3m30s
A delivery target URL is itself a credential: a Slack incoming webhook URL is a bearer token. Three paths still reproduced it in full. Transport failures were the worst of them. net/http embeds the request URL in every *url.Error it returns, so any DNS, TLS, timeout or dial failure wrote the whole webhook URL into DeliveryResult.Error — on disk, in the per-webhook database, behind a json tag that a REST API would serialize. maskURL moves to url_mask.go and is exported as MaskURL, and maskURLError joins it: it rebuilds the *url.Error with the URL masked, keeping the operation and the wrapped cause, so a refused connection still reads differently from a DNS failure or a timeout and errors.Is/As/Timeout still work. It is applied where the errors are raised — executeHTTPRequest, shared by the Slack and HTTP targets, and the request-construction paths — so downstream wrapping is safe by construction. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment; its error is logged and shown. The SSRF rejection log now records only the masked URL, and loadTargetMap hands the event log page TargetViews and a delivery projection instead of raw target rows, so the stored config blob has no path to that template either.
197 lines
4.8 KiB
Go
197 lines
4.8 KiB
Go
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"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// The path of a Slack incoming webhook URL is the credential:
|
|
// whoever holds these segments can post to the channel
|
|
// forever. None of them may reach a stored delivery error,
|
|
// which lives on disk in the per-webhook database and is
|
|
// serialized by the JSON tag on DeliveryResult.Error.
|
|
const (
|
|
maskSecretPath = "/services/T00000000/B00000000/" +
|
|
"XXXXXXXXXXXXXXXXXXXXXXXX"
|
|
)
|
|
|
|
// assertNoCredential fails if the whole path or any single
|
|
// segment of it survived into the message, so a partial leak
|
|
// fails the test too.
|
|
func assertNoCredential(t *testing.T, msg string) {
|
|
t.Helper()
|
|
|
|
segments := []string{
|
|
maskSecretPath,
|
|
"services",
|
|
"T00000000",
|
|
"B00000000",
|
|
"XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
}
|
|
|
|
for _, segment := range segments {
|
|
assert.NotContains(t, msg, segment)
|
|
}
|
|
}
|
|
|
|
// storedDeliveryError returns the error string persisted for a
|
|
// delivery, which is what an operator and any future API read.
|
|
func storedDeliveryError(
|
|
t *testing.T, db *gorm.DB, deliveryID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
var result database.DeliveryResult
|
|
|
|
require.NoError(t, db.Where(
|
|
"delivery_id = ?", deliveryID,
|
|
).First(&result).Error)
|
|
|
|
return result.Error
|
|
}
|
|
|
|
// deliverSlackTo runs a Slack delivery against webhookURL and
|
|
// returns the error string it persisted.
|
|
func deliverSlackTo(
|
|
t *testing.T, webhookURL string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
db := testWebhookDB(t)
|
|
e := testEngine(t, 1)
|
|
targetID := uuid.New().String()
|
|
|
|
slackCfg, err := json.Marshal(
|
|
delivery.SlackTargetConfig{
|
|
WebhookURL: webhookURL,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
event := seedEvent(t, db, `{"test":true}`)
|
|
|
|
dlv := seedDelivery(
|
|
t, db, event.ID, targetID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := buildSlackDelivery(
|
|
dlv, event, targetID,
|
|
"test-slack-mask", string(slackCfg),
|
|
)
|
|
|
|
e.ExportDeliverSlack(context.TODO(), db, d)
|
|
|
|
assertDeliveryStatus(t, db, dlv.ID,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
|
|
return storedDeliveryError(t, db, dlv.ID)
|
|
}
|
|
|
|
// TestDeliverSlack_TransportErrorMasksWebhookURL is the
|
|
// load-bearing regression test: a transport failure must not
|
|
// persist the webhook URL's credential into the database, and
|
|
// must still say what went wrong and where.
|
|
func TestDeliverSlack_TransportErrorMasksWebhookURL(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
// A server closed before use gives a deterministic
|
|
// transport failure against a known host.
|
|
ts := httptest.NewServer(http.NewServeMux())
|
|
host := ts.URL
|
|
|
|
ts.Close()
|
|
|
|
errMsg := deliverSlackTo(t, host+maskSecretPath)
|
|
|
|
require.NotEmpty(t, errMsg)
|
|
assertNoCredential(t, errMsg)
|
|
|
|
// The diagnostic value survives: the operation, the host
|
|
// and the transport failure are all still reported, and
|
|
// only the path is elided.
|
|
assert.Contains(t, errMsg, "sending request")
|
|
assert.Contains(t, errMsg, "Post")
|
|
assert.Contains(t, errMsg, host+"/...")
|
|
assert.Contains(t, errMsg, "connection refused")
|
|
}
|
|
|
|
// TestDeliverSlack_UnparsableURLMasksWebhookURL covers the
|
|
// other error path out of a Slack attempt: url.Parse also
|
|
// embeds the whole URL in the error it returns.
|
|
func TestDeliverSlack_UnparsableURLMasksWebhookURL(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
errMsg := deliverSlackTo(
|
|
t,
|
|
"https://hooks.slack.com"+maskSecretPath+"\n",
|
|
)
|
|
|
|
require.NotEmpty(t, errMsg)
|
|
assertNoCredential(t, errMsg)
|
|
assert.Contains(t, errMsg, "invalid control character")
|
|
}
|
|
|
|
// TestDoHTTPRequest_TransportErrorMasksURL proves the HTTP
|
|
// target's transport errors are masked too; its destination
|
|
// URL can carry a token in a query string.
|
|
func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ts := httptest.NewServer(http.NewServeMux())
|
|
host := ts.URL
|
|
|
|
ts.Close()
|
|
|
|
e := testEngine(t, 1)
|
|
|
|
cfg, err := e.ExportParseHTTPConfig(
|
|
newHTTPTargetConfig(host + maskSecretPath),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
|
|
context.TODO(), cfg,
|
|
&database.Event{Body: `{"test":true}`},
|
|
)
|
|
require.Error(t, reqErr)
|
|
assert.Zero(t, statusCode)
|
|
|
|
assertNoCredential(t, reqErr.Error())
|
|
assert.Contains(t, reqErr.Error(), host+"/...")
|
|
assert.Contains(
|
|
t, reqErr.Error(), "connection refused",
|
|
)
|
|
}
|
|
|
|
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
|
// validator's error does not carry the submitted URL, which
|
|
// the handler both logs and shows.
|
|
func TestValidateTargetURL_UnparsableURLIsMasked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := delivery.ValidateTargetURL(
|
|
context.TODO(),
|
|
"https://hooks.slack.com"+maskSecretPath+"\n",
|
|
)
|
|
require.Error(t, err)
|
|
|
|
assertNoCredential(t, err.Error())
|
|
assert.Contains(t, err.Error(), "invalid URL")
|
|
}
|