All checks were successful
check / check (push) Successful in 3m4s
The SSRF blocklist had no escape hatch, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination. ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery targets may reach despite the default blocklist. It is an allowlist and only ever adds destinations: there is no boolean, and no value disables SSRF protection wholesale. Empty, it adds nothing and the guard permits and refuses the same addresses it did before, save the two spellings named below. A small set of addresses is refused before the allowlist is consulted, so no supplied CIDR opens one — not the exact address, not a supernet, not 0.0.0.0/0 or ::/0. alwaysBlockedNetworks in internal/delivery/ssrf.go is the authoritative list and states the membership criterion in full; it is deliberately not copied here, because a copy drifts out of date. In short: the provider fixes the address, so a host route for it collides with nothing the operator runs, and reaching it discloses credentials or user data. A publicly routable address never qualifies however well it meets both — nothing in this set can be reopened, so blocking one here would leave the operator no escape hatch at all. Those belong in blockedNetworks, which an allowlist can override. Every entry is already inside the default blocklist, which is what makes it unconditional rather than newly blocked, with two exceptions that are the one behaviour change visible when the allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the IPv4-compatible and NAT64 spellings of 169.254.169.254, were reachable before and are refused now. net.IPNet.Contains normalises only the IPv4-mapped form via To4(), so 169.254.0.0/16 never matched those two. The ten it does cover now report a metadata error rather than the generic private-range one. The policy now lives in one function, Guard.checkIP, which both target-creation validation and the delivery dialer call. The two paths previously decided separately, which is how they came to disagree about a destination. The guard is built once from config and injected via fx into both the handlers and the delivery engine, so there is a single instance and a single answer. A set-but-unparseable value aborts startup naming the variable, reusing the existing envPrefixList parser. A non-empty list is logged at startup with the blocks spelled out, not counted, so the hole is visible in the log of any deployment that has one. Tests: an allowlisted loopback CIDR both validates and delivers to a live server (and the same URL still fails without the allowlist); a private address outside the listed block stays refused on both paths; every unconditionally blocked address stays refused on both paths under an allowlist that covers it, and the set itself is pinned entry by entry; public addresses are unaffected either way; and config coverage for parsing, startup abort, and the warning's contents.
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.NewTestGuard().ValidateTargetURL(
|
|
context.TODO(),
|
|
"https://hooks.slack.com"+maskSecretPath+"\n",
|
|
)
|
|
require.Error(t, err)
|
|
|
|
assertNoCredential(t, err.Error())
|
|
assert.Contains(t, err.Error(), "invalid URL")
|
|
}
|