tests/enrollment_webhook_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"bufio"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/go-resty/resty/v2"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/pkg/testimage"
"github.com/shellhub-io/shellhub/pkg/testrun"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
enrollmentWebhookPort = "9876"
enrollmentWebhookSecret = "s3cr3t"
)
// TestEnrollmentWebhook enrolls devices with webhook-mode keys whose integrator is a stub that runs
// in the server's network namespace: it answers each call with the decision its path names and
// logs every call it receives. The server reaches it on loopback, which the stack allows the
// webhook to call; every other private address stays behind the SSRF guard.
func TestEnrollmentWebhook(t *testing.T) {
t.Parallel()
compose := newConfiguredSSHEnvironment(t, t.Context(),
environment.New(t, run).WithEnv("SHELLHUB_PROVISIONING_KEY_WEBHOOK_ALLOWED_CIDRS", "127.0.0.0/8"),
newNamespace)
webhook := startEnrollmentWebhook(t, compose)
newKey := func(t *testing.T, req *requests.CreateProvisioningKey) string {
t.Helper()
req.Mode = string(models.ProvisioningKeyModeWebhook)
req.WebhookSecret = enrollmentWebhookSecret
return compose.CreateProvisioningKey(t, req).Key
}
t.Run("the integrator's answer decides the device", func(t *testing.T) {
cases := []struct {
decision string
status models.DeviceStatus
mac string
}{
{decision: "accept", status: models.DeviceStatusAccepted, mac: "02:00:00:00:80:01"},
{decision: "reject", status: models.DeviceStatusRejected, mac: "02:00:00:00:80:02"},
{decision: "defer", status: models.DeviceStatusPending, mac: "02:00:00:00:80:03"},
}
for _, tc := range cases {
t.Run("an answer to "+tc.decision, func(t *testing.T) {
name := "webhook-" + tc.decision
key := newKey(t, &requests.CreateProvisioningKey{Name: name, WebhookURL: webhookURL("/" + tc.decision)})
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, name, tc.mac))
assert.Equal(t, tc.status, device.Status)
webhook.awaitCalls(t, device.UID, 1)
})
}
})
t.Run("the call is signed with the key's secret and describes the enrollment", func(t *testing.T) {
key := compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{
Name: "webhook-payload",
Mode: string(models.ProvisioningKeyModeWebhook),
WebhookURL: webhookURL("/defer"),
WebhookSecret: enrollmentWebhookSecret,
})
req := newKeyedDeviceAuthRequest(t, key.Key, "webhook-payload", "02:00:00:00:80:04")
before := wallClock.Now()
device := enroll(t, compose, req)
call := webhook.awaitCalls(t, device.UID, 1)[0]
mac := hmac.New(sha256.New, []byte(enrollmentWebhookSecret))
mac.Write([]byte(call.Body))
assert.Equal(t, hex.EncodeToString(mac.Sum(nil)), call.Signature)
payload := call.payload(t)
assert.Equal(t, ShellHubNamespace, payload.TenantID)
assert.Equal(t, key.ID, payload.ProvisioningKeyID)
assert.Equal(t, "webhook-payload", payload.ProvisioningKeyName)
assert.Equal(t, device.UID, payload.DeviceUID)
assert.Equal(t, "02:00:00:00:80:04", payload.Identity)
assert.Equal(t, "webhook-payload", payload.Hostname)
assert.Equal(t, req.Info, payload.Info)
assert.NotEmpty(t, payload.SourceIP)
assert.WithinRange(t, payload.Timestamp, before.Add(-time.Minute), wallClock.Now().Add(time.Minute))
callback, err := url.Parse(payload.CallbackURL)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(callback.Path, "/api/devices/enroll/callback/"), payload.CallbackURL)
})
t.Run("an integrator slower than the key's timeout leaves the device pending", func(t *testing.T) {
const delay = 10 * time.Second
key := newKey(t, &requests.CreateProvisioningKey{
Name: "webhook-slow",
WebhookURL: webhookURL("/accept?delay=" + delay.String()),
WebhookTimeout: 1,
})
started := wallClock.Now()
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "webhook-slow", "02:00:00:00:80:05"))
assert.Less(t, wallClock.Now().Sub(started), models.ProvisioningKeyWebhookDefaultTimeout*time.Second,
"the server waited for the integrator past the key's timeout")
assert.Equal(t, models.DeviceStatusPending, device.Status)
webhook.awaitCalls(t, device.UID, 1)
})
t.Run("a deferred decision is delivered through the callback", func(t *testing.T) {
cases := []struct {
decision string
status models.DeviceStatus
mac string
}{
{decision: "accept", status: models.DeviceStatusAccepted, mac: "02:00:00:00:80:06"},
{decision: "reject", status: models.DeviceStatusRejected, mac: "02:00:00:00:80:07"},
}
for _, tc := range cases {
t.Run("a callback to "+tc.decision, func(t *testing.T) {
name := "callback-" + tc.decision
key := newKey(t, &requests.CreateProvisioningKey{Name: name, WebhookURL: webhookURL("/defer")})
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, name, tc.mac))
require.Equal(t, models.DeviceStatusPending, device.Status)
callback := webhook.awaitCalls(t, device.UID, 1)[0].payload(t).CallbackURL
resp := postEnrollmentCallback(t, compose, callback, tc.decision)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
assert.Equal(t, tc.status, requireDevice(t, compose, device.UID).Status)
})
}
})
t.Run("a callback redeems its decision only once", func(t *testing.T) {
key := newKey(t, &requests.CreateProvisioningKey{Name: "callback-once", WebhookURL: webhookURL("/defer")})
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "callback-once", "02:00:00:00:80:08"))
callback := webhook.awaitCalls(t, device.UID, 1)[0].payload(t).CallbackURL
resp := postEnrollmentCallback(t, compose, callback, "accept")
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
resp = postEnrollmentCallback(t, compose, callback, "reject")
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode(), resp.String())
assert.Equal(t, models.DeviceStatusAccepted, requireDevice(t, compose, device.UID).Status)
})
t.Run("a callback past the key's callback window decides nothing", func(t *testing.T) {
key := newKey(t, &requests.CreateProvisioningKey{
Name: "callback-expired",
WebhookURL: webhookURL("/defer"),
WebhookCallbackTTL: 1,
})
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "callback-expired", "02:00:00:00:80:09"))
callback := webhook.awaitCalls(t, device.UID, 1)[0].payload(t).CallbackURL
time.Sleep(3 * time.Second)
resp := postEnrollmentCallback(t, compose, callback, "accept")
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode(), resp.String())
assert.Equal(t, models.DeviceStatusPending, requireDevice(t, compose, device.UID).Status)
})
t.Run("a pending device is decided on its next authentication once the integrator answers", func(t *testing.T) {
key := newKey(t, &requests.CreateProvisioningKey{Name: "webhook-reconciled", WebhookURL: webhookURL("/defer")})
req := newKeyedDeviceAuthRequest(t, key, "webhook-reconciled", "02:00:00:00:80:10")
device := enroll(t, compose, req)
require.Equal(t, models.DeviceStatusPending, device.Status)
compose.UpdateProvisioningKey(t, "webhook-reconciled", map[string]any{"webhook_url": webhookURL("/accept")})
requireStatusOnReauth(t, compose, req, device.UID, models.DeviceStatusAccepted)
calls := webhook.awaitCalls(t, device.UID, 2)
assert.Equal(t, "/accept", calls[1].Path)
})
t.Run("a pending device asks the integrator again at most once a minute", func(t *testing.T) {
key := newKey(t, &requests.CreateProvisioningKey{Name: "webhook-throttled", WebhookURL: webhookURL("/defer")})
req := newKeyedDeviceAuthRequest(t, key, "webhook-throttled", "02:00:00:00:80:11")
device := enroll(t, compose, req)
webhook.awaitCalls(t, device.UID, 1)
lastAttempt := func() time.Time {
t.Helper()
attempt := requireDevice(t, compose, device.UID).LastEnrollmentAttemptAt
require.NotNil(t, attempt)
return *attempt
}
reauthDevice(t, compose, req, device.UID)
webhook.awaitCalls(t, device.UID, 2)
reconciled := lastAttempt()
reauthDevice(t, compose, req, device.UID)
require.Equal(t, reconciled, lastAttempt(), "authenticating right after a reconcile asked the integrator again")
compose.AgeLastEnrollmentAttempt(t, device.UID, models.EnrollmentReconcileInterval-5*time.Second)
almost := lastAttempt()
reauthDevice(t, compose, req, device.UID)
require.Equal(t, almost, lastAttempt(), "authenticating before the interval ran out asked the integrator again")
compose.AgeLastEnrollmentAttempt(t, device.UID, models.EnrollmentReconcileInterval+time.Second)
past := lastAttempt()
reauthDevice(t, compose, req, device.UID)
require.True(t, lastAttempt().After(past), "authenticating past the interval did not ask the integrator again")
webhook.awaitCalls(t, device.UID, 3)
assert.Equal(t, models.DeviceStatusPending, requireDevice(t, compose, device.UID).Status)
})
t.Run("an integrator on a private address outside the allowed ranges is never called", func(t *testing.T) {
key := newKey(t, &requests.CreateProvisioningKey{
Name: "webhook-private",
WebhookURL: "http://" + string(environment.ServiceServer) + ":" + enrollmentWebhookPort + "/accept",
})
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "webhook-private", "02:00:00:00:80:12"))
assert.Equal(t, models.DeviceStatusPending, device.Status)
assert.Never(t, func() bool {
calls, err := webhook.calls(t.Context(), device.UID)
return err != nil || len(calls) > 0
}, time.Second, 250*time.Millisecond, "the server called the integrator at the server's own network address")
})
}
func webhookURL(path string) string {
return "http://127.0.0.1:" + enrollmentWebhookPort + path
}
func postEnrollmentCallback(t *testing.T, compose *environment.DockerCompose, callbackURL, decision string) *resty.Response {
t.Helper()
callback, err := url.Parse(callbackURL)
require.NoError(t, err)
resp, err := compose.Anonymous(t.Context()).
SetBody(map[string]string{"decision": decision}).
Post(callback.Path)
require.NoError(t, err)
return resp
}
type enrollmentWebhook struct {
container testcontainers.Container
}
type enrollmentWebhookCall struct {
Path string `json:"path"`
Signature string `json:"signature"`
Body string `json:"body"`
}
type enrollmentWebhookPayload struct {
TenantID string `json:"tenant_id"`
ProvisioningKeyID string `json:"provisioning_key_id"`
ProvisioningKeyName string `json:"provisioning_key_name"`
DeviceUID string `json:"device_uid"`
Identity string `json:"identity"`
Hostname string `json:"hostname"`
Info *requests.DeviceInfo `json:"info"`
SourceIP string `json:"source_ip"`
Timestamp time.Time `json:"timestamp"`
CallbackURL string `json:"callback_url"`
}
func (c enrollmentWebhookCall) payload(t *testing.T) enrollmentWebhookPayload {
t.Helper()
var payload enrollmentWebhookPayload
require.NoError(t, json.Unmarshal([]byte(c.Body), &payload))
return payload
}
func startEnrollmentWebhook(t *testing.T, compose *environment.DockerCompose) *enrollmentWebhook {
t.Helper()
server := compose.Service(environment.ServiceServer).GetContainerID()
buildArgs, err := testimage.BuildArgs("..")
require.NoError(t, err)
c, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Repo: "enrollment-webhook",
Tag: "test",
Context: "cmd/enrollment-webhook",
KeepImage: true,
BuildArgs: buildArgs,
BuildOptionsModifier: func(options *client.ImageBuildOptions) {
options.Labels = map[string]string{testrun.RunLabel: run.ID()}
},
},
Labels: run.Labels(),
Env: map[string]string{"ADDRESS": ":" + enrollmentWebhookPort},
HostConfigModifier: func(hc *container.HostConfig) {
hc.NetworkMode = container.NetworkMode("container:" + server)
},
WaitingFor: wait.ForLog("listening"),
},
Started: true,
Logger: log.New(io.Discard, "", log.LstdFlags),
})
require.NoError(t, err)
t.Cleanup(func() { _ = c.Terminate(context.Background()) })
return &enrollmentWebhook{container: c}
}
func (w *enrollmentWebhook) calls(ctx context.Context, uid string) ([]enrollmentWebhookCall, error) {
reader, err := w.container.Logs(ctx)
if err != nil {
return nil, err
}
defer reader.Close() //nolint:errcheck // the logs are read in full before it closes
calls := []enrollmentWebhookCall{}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
var call enrollmentWebhookCall
if json.Unmarshal(scanner.Bytes(), &call) != nil || call.Path == "" {
continue
}
var payload enrollmentWebhookPayload
if err := json.Unmarshal([]byte(call.Body), &payload); err != nil {
return nil, err
}
if payload.DeviceUID == uid {
calls = append(calls, call)
}
}
return calls, scanner.Err()
}
func (w *enrollmentWebhook) awaitCalls(t *testing.T, uid string, count int) []enrollmentWebhookCall {
t.Helper()
var calls []enrollmentWebhookCall
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
calls, err = w.calls(t.Context(), uid)
assert.NoError(tt, err)
assert.Len(tt, calls, count)
}, 10*time.Second, 500*time.Millisecond)
return calls
}