All checks were successful
check / check (push) Successful in 9s
Go embeds the request URL in *url.Error, so any transport failure — DNS, TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook URL into the per-webhook SQLite database via DeliveryResult.Error. That field is tagged json:"error,omitempty", so a future REST API would have served it. maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS vs TLS vs timeout still read differently and errors.Is/As and Timeout() keep working; only path, query and userinfo are dropped. Applied where the errors are born, which covers both the Slack and HTTP targets. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment. The SSRF rejection log now logs the masked URL, and source_logs.html receives view types rather than raw rows, so no config blob is reachable from that template. MaskURL is now the single masker for the whole tree.
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")
|
|
}
|