All checks were successful
check / check (push) Successful in 4m17s
An http target's destination is frequently a Slack, Discord or Teams incoming-webhook endpoint whose path segments are the credential — the same property that made the Slack target's webhook URL a bearer token. The source detail page rendered it in full, so the leak closed for slack targets stayed reachable through a different target type. Render it through the existing MaskURL, which reduces a URL to scheme and host. The field accepts an arbitrary URL, so no path segment can be assumed non-secret and none is shown.
229 lines
5.2 KiB
Go
229 lines
5.2 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// The secret path segments of a Slack incoming webhook URL.
|
|
// Holding them is enough to post to the channel forever, so
|
|
// they must never reach the rendered page.
|
|
const (
|
|
slackSecretPath = "/services/T00000000/B00000000/" +
|
|
"XXXXXXXXXXXXXXXXXXXXXXXX"
|
|
slackWebhookURL = "https://hooks.slack.com" +
|
|
slackSecretPath
|
|
)
|
|
|
|
// seedConfiguredTarget inserts a target with a stored config
|
|
// blob and returns it.
|
|
func seedConfiguredTarget(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
webhookID string,
|
|
targetType database.TargetType,
|
|
config string,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
tgt := &database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "t-" + string(targetType),
|
|
Type: targetType,
|
|
Active: true,
|
|
Config: config,
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Omit(clause.Associations).Create(tgt).Error,
|
|
)
|
|
|
|
return tgt
|
|
}
|
|
|
|
// renderSourceDetailPage runs the real source detail handler
|
|
// for a webhook and returns the rendered HTML.
|
|
func renderSourceDetailPage(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
"/source/"+webhookID,
|
|
nil,
|
|
)
|
|
|
|
for _, c := range authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
) {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add(paramSourceID, webhookID)
|
|
|
|
req = req.WithContext(
|
|
context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
),
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleSourceDetail().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
return w.Body.String()
|
|
}
|
|
|
|
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
|
// load-bearing regression test for the credential leak: the
|
|
// rendered page must show the Slack target without any of the
|
|
// secret path segments of its webhook URL.
|
|
func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeSlack,
|
|
`{"webhookUrl":"`+slackWebhookURL+`"}`,
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.NotContains(t, body, "T00000000")
|
|
assert.NotContains(t, body, "B00000000")
|
|
assert.NotContains(
|
|
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
)
|
|
assert.NotContains(t, body, "webhookUrl")
|
|
|
|
assert.Contains(t, body, "Webhook URL")
|
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
}
|
|
|
|
// TestHandleSourceDetail_MasksHTTPDestinationURL is the
|
|
// regression test for the same leak reached through the http
|
|
// target: its destination is routinely an incoming-webhook
|
|
// endpoint whose path segments are the credential, so the
|
|
// rendered page must not contain them.
|
|
func TestHandleSourceDetail_MasksHTTPDestinationURL(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeHTTP,
|
|
`{"url":"`+slackWebhookURL+`"}`,
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.NotContains(t, body, "T00000000")
|
|
assert.NotContains(t, body, "B00000000")
|
|
assert.NotContains(
|
|
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
)
|
|
|
|
assert.Contains(t, body, "Destination URL")
|
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
}
|
|
|
|
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
|
// other target types render labelled fields rather than the
|
|
// stored blob.
|
|
func TestHandleSourceDetail_RendersNamedTargetFields(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeHTTP,
|
|
`{"url":"https://example.com/hook","timeout":30,`+
|
|
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeDatabase,
|
|
`{"expiry":"720h"}`,
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetType("carrier-pigeon"),
|
|
`{"beak":"sharp"}`,
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(t, body, "Destination URL")
|
|
assert.Contains(t, body, "https://example.com/...")
|
|
assert.Contains(t, body, "Timeout")
|
|
assert.Contains(t, body, "1 configured")
|
|
assert.NotContains(t, body, "sekrit")
|
|
|
|
assert.Contains(t, body, "Archive Expiry")
|
|
assert.Contains(t, body, "720h")
|
|
|
|
// An unknown type gets the neutral placeholder, never the
|
|
// stored blob.
|
|
assert.Contains(t, body, "(unavailable)")
|
|
assert.NotContains(t, body, "beak")
|
|
}
|