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 }