Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ nylas webhook test payload [trigger-type] # Generate test payload
nylas webhook server # Interactive preflight (offers cloudflared tunnel)
nylas webhook server --no-tunnel # Loopback-only (skip preflight)
nylas webhook server --port 8080 --tunnel cloudflared --secret xxx # Public tunnel + HMAC verify
nylas webhook server --tunnel cloudflared --register --triggers message.created # Auto-create webhook + fetch secret + cleanup on exit
```

**Details:** `docs/commands/webhooks.md`
Expand Down
16 changes: 16 additions & 0 deletions docs/commands/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,29 @@ nylas webhook server --tunnel cloudflared --secret your-webhook-secret

# Custom port with a tunnel
nylas webhook server --port 8080 --tunnel cloudflared --secret your-webhook-secret

# Auto-register: create the Nylas webhook for the tunnel URL, fetch its
# secret automatically, and delete it again on exit (no manual setup)
nylas webhook server --tunnel cloudflared --register --triggers message.created
```

When `--tunnel` is set, `--secret` is required (or pass `--allow-unsigned`
to opt out explicitly). The interactive preflight will prompt for a
secret inline when you accept the tunnel; leaving it empty opts into
unsigned mode.

**Auto-registration (`--register`):** the quick-tunnel URL changes on every
restart, so registering it by hand is tedious. `--register` does it for you:
after the tunnel comes up the CLI creates a Nylas webhook pointing at the live
URL, pulls back the signing secret into memory (so signature verification is
on without you copying anything), and deletes the webhook when the server
stops. A stale-webhook sweep on start also removes any auto-registered webhook
left behind by a previous hard kill. Choose the events with `--triggers`
(comma-separated or repeated); you'll be prompted if it's omitted on a
terminal. With `--register` you do **not** pass `--secret` — it's fetched from
Nylas. `--register` implies `--tunnel cloudflared` and cannot be combined with
`--secret`, `--allow-unsigned`, or `--no-tunnel`.

**Cloudflared install:**

On macOS, the preflight will offer to run `brew install cloudflared` for
Expand Down
78 changes: 64 additions & 14 deletions internal/adapters/webhookserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net"
"net/http"
"sync"
"sync/atomic"
"time"

"github.com/nylas/cli/internal/ports"
Expand Down Expand Up @@ -85,6 +86,12 @@ type Server struct {
mu sync.RWMutex
startedAt time.Time
closeOnce sync.Once

// awaitingSecret is set while --register has the listener live but hasn't
// fetched the signing secret yet. POST events are rejected (503) during
// this window so the public tunnel never processes an unsigned event; the
// GET challenge stays open so Nylas's create-time verification succeeds.
awaitingSecret atomic.Bool
}

// NewServer creates a new webhook server.
Expand Down Expand Up @@ -112,6 +119,37 @@ func (s *Server) SetTunnel(tunnel ports.Tunnel) {
s.tunnel = tunnel
}

// AwaitSecret marks the server as waiting for its signing secret. Call it
// before Start in --register mode so POST events are rejected until the secret
// is installed by UpdateSecret.
func (s *Server) AwaitSecret() {
s.awaitingSecret.Store(true)
}

// UpdateSecret sets the HMAC secret (and replay window) after the server has
// started, and clears the awaiting-secret gate. Auto-registration needs this:
// the webhook secret is only minted by Nylas once the public tunnel URL exists
// and the webhook is created, which can't happen until the listener is already
// up to answer Nylas's create-time challenge. Safe for concurrent use with
// in-flight requests.
func (s *Server) UpdateSecret(secret string, maxEventAge time.Duration) {
s.mu.Lock()
s.config.WebhookSecret = secret
s.config.MaxEventAge = maxEventAge
s.mu.Unlock()
// Clear the gate last so no request is admitted before the secret is live.
s.awaitingSecret.Store(false)
}

// signingConfig returns the current HMAC secret and replay window under the
// read lock so a concurrent UpdateSecret can't race the unsynchronised field
// reads in handleWebhook.
func (s *Server) signingConfig() (secret string, maxEventAge time.Duration) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config.WebhookSecret, s.config.MaxEventAge
}

// Start starts the webhook server and optional tunnel.
func (s *Server) Start(ctx context.Context) error {
// Create HTTP server
Expand Down Expand Up @@ -259,6 +297,15 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}

// Reject POST events while --register is still fetching the signing secret.
// Without this, the live public tunnel would process unsigned events during
// the registration window. The GET challenge above is already handled, so
// Nylas's create-time verification still succeeds.
if s.awaitingSecret.Load() {
http.Error(w, "Webhook registration in progress", http.StatusServiceUnavailable)
return
}

// Cap request body size so a malicious sender on a public tunnel can't
// drive unbounded RAM allocation. MaxBytesReader closes the body and
// returns an error from ReadAll once the limit is exceeded.
Expand All @@ -274,13 +321,17 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
}
defer func() { _ = r.Body.Close() }()

// Read the signing posture once under the lock — auto-registration can swap
// the secret in via UpdateSecret while requests are in flight.
webhookSecret, maxEventAge := s.signingConfig()

signature := r.Header.Get("X-Nylas-Signature")
if s.config.WebhookSecret != "" {
if webhookSecret != "" {
if signature == "" {
http.Error(w, "Missing webhook signature", http.StatusUnauthorized)
return
}
if !s.verifySignature(body, signature) {
if !VerifySignature(body, signature, webhookSecret) {
http.Error(w, "Invalid webhook signature", http.StatusForbidden)
return
}
Expand All @@ -293,7 +344,7 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
Headers: make(map[string]string),
RawBody: body,
Signature: signature,
Verified: s.config.WebhookSecret != "",
Verified: webhookSecret != "",
}

// Copy relevant headers
Expand Down Expand Up @@ -332,23 +383,23 @@ func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
// When configured, reject events whose CloudEvents `time` field is
// older than the allowed skew. Payloads without `time` are covered
// by the signed-body dedupe below.
if s.config.WebhookSecret != "" && s.config.MaxEventAge > 0 {
if webhookSecret != "" && maxEventAge > 0 {
if rawTime, ok := payload["time"].(string); ok {
eventTime, terr := time.Parse(time.RFC3339, rawTime)
if terr != nil {
http.Error(w, "Invalid event timestamp", http.StatusBadRequest)
return
}
skew := time.Since(eventTime)
if skew > s.config.MaxEventAge || skew < -s.config.MaxEventAge {
if skew > maxEventAge || skew < -maxEventAge {
http.Error(w, "Event timestamp outside allowed skew", http.StatusUnauthorized)
return
}
}
}
}

if s.shouldSuppressSignedReplay(signature, time.Now()) {
if s.shouldSuppressSignedReplay(signature, webhookSecret, maxEventAge, time.Now()) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Duplicate webhook ignored"))
return
Expand Down Expand Up @@ -451,20 +502,19 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
_ = rootTemplate.Execute(w, data) // best-effort response
}

// verifySignature verifies the webhook signature using HMAC-SHA256.
func (s *Server) verifySignature(payload []byte, signature string) bool {
return VerifySignature(payload, signature, s.config.WebhookSecret)
}

func (s *Server) shouldSuppressSignedReplay(signature string, now time.Time) bool {
if s.config.WebhookSecret == "" || s.config.MaxEventAge <= 0 || signature == "" {
// shouldSuppressSignedReplay reports whether a signed event with this signature
// was already seen inside the replay window. The secret/maxEventAge are passed
// in (snapshotted under signingConfig in the caller) rather than read from
// s.config here, so a concurrent UpdateSecret cannot race these reads.
func (s *Server) shouldSuppressSignedReplay(signature, secret string, maxEventAge time.Duration, now time.Time) bool {
if secret == "" || maxEventAge <= 0 || signature == "" {
return false
}

s.mu.Lock()
defer s.mu.Unlock()

cutoff := now.Add(-s.config.MaxEventAge)
cutoff := now.Add(-maxEventAge)
for key, seenAt := range s.seenSignatures {
if seenAt.Before(cutoff) {
delete(s.seenSignatures, key)
Expand Down
131 changes: 123 additions & 8 deletions internal/adapters/webhookserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -266,22 +267,16 @@ func TestServer_EventsChannel(t *testing.T) {
func TestServer_SignatureVerification(t *testing.T) {
// #nosec G101 -- This is a test secret, not a real credential
secret := "test-webhook-secret"
server := NewServer(ports.WebhookServerConfig{
Port: 3006,
Path: "/webhook",
WebhookSecret: secret,
})

t.Run("valid_signature", func(t *testing.T) {
payload := []byte(`{"type":"message.created"}`)
// Generate valid signature (HMAC-SHA256)
valid := server.verifySignature(payload, "invalid-signature")
valid := VerifySignature(payload, "invalid-signature", secret)
assert.False(t, valid) // Invalid signature should fail
})

t.Run("missing_signature", func(t *testing.T) {
payload := []byte(`{"type":"message.created"}`)
valid := server.verifySignature(payload, "")
valid := VerifySignature(payload, "", secret)
assert.False(t, valid) // Empty signature should fail
})
}
Expand Down Expand Up @@ -333,6 +328,126 @@ func TestServer_GetPublicURL(t *testing.T) {
assert.Equal(t, "http://127.0.0.1:8080/webhook", url)
}

// TestServer_UpdateSecret_SwapsVerification verifies the auto-registration
// path: a server started without a secret accepts unsigned events, but once
// UpdateSecret installs the Nylas-minted secret, unsigned/forged events are
// rejected and only correctly-signed events pass. This is the behaviour that
// makes `webhooks server --register` safe — the window before the secret is
// known is closed the instant UpdateSecret runs.
func TestServer_UpdateSecret_SwapsVerification(t *testing.T) {
server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"})
handler := http.HandlerFunc(server.handleWebhook)
payload := []byte(`{"type":"message.created","id":"event-1"}`)

// Before a secret is set, unsigned events are accepted (no verification).
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "unsigned event should be accepted before UpdateSecret")

// Swap in the secret, as auto-registration does once Nylas mints it.
secret := "minted-by-nylas"
server.UpdateSecret(secret, defaultTestMaxEventAge)

t.Run("unsigned now rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

t.Run("forged signature rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
req.Header.Set("X-Nylas-Signature", "deadbeef")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})

t.Run("correctly signed accepted", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
}

const defaultTestMaxEventAge = 5 * time.Minute

// TestServer_AwaitSecret_RejectsPostUntilSecretSet verifies the registration
// window is closed: while --register has the listener live but no secret yet,
// POST events are rejected (503) so the public tunnel never processes an
// unsigned event, while the GET challenge Nylas uses to verify the URL still
// works. Once UpdateSecret installs the secret, signed POSTs are accepted.
func TestServer_AwaitSecret_RejectsPostUntilSecretSet(t *testing.T) {
server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"})
server.AwaitSecret()
handler := http.HandlerFunc(server.handleWebhook)
payload := []byte(`{"type":"message.created"}`)

t.Run("POST rejected while awaiting secret", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, 0, server.GetStats().EventsReceived)
})

t.Run("GET challenge still answered", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/webhook?challenge=abc123", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "abc123", rec.Body.String())
})

t.Run("signed POST accepted after secret installed", func(t *testing.T) {
secret := "minted-by-nylas"
server.UpdateSecret(secret, defaultTestMaxEventAge)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
}

// TestServer_UpdateSecret_ConcurrentRequests drives the handler concurrently
// with UpdateSecret under `-race` to guard the secret swap. Requests are
// correctly signed with distinct bodies so each one passes verification and
// reaches BOTH racy read paths: signingConfig() and the seenSignatures write
// in shouldSuppressSignedReplay — exercised concurrently with UpdateSecret's
// config writes. The assertions are loose; the point is the race detector.
func TestServer_UpdateSecret_ConcurrentRequests(t *testing.T) {
const secret = "stable-secret"
server := NewServer(ports.WebhookServerConfig{Port: 0, Path: "/webhook"})
server.UpdateSecret(secret, defaultTestMaxEventAge)
handler := http.HandlerFunc(server.handleWebhook)

var wg sync.WaitGroup
// Writers: re-install the (same) secret to race the config writes.
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
server.UpdateSecret(secret, defaultTestMaxEventAge)
}()
}
// Readers: signed, distinct-body POSTs that reach the replay map write.
for i := range 50 {
wg.Add(1)
go func(i int) {
defer wg.Done()
payload := fmt.Appendf(nil, `{"type":"message.created","id":"e-%d"}`, i)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload))
req.Header.Set("X-Nylas-Signature", signWebhookPayload(secret, payload))
handler.ServeHTTP(httptest.NewRecorder(), req)
}(i)
}
wg.Wait()
}

func signWebhookPayload(secret string, payload []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(payload)
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/webhook/prompter.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import (
type preflightPrompter interface {
Confirm(message string, defaultYes bool) (bool, error)
Password(message string) (string, error)
// Ask reads a line of plain text, returning defaultValue when the user
// submits an empty line. EOF is propagated unchanged so callers can tell
// cancellation from an accepted default.
Ask(message, defaultValue string) (string, error)
}

// stdinPrompter is the production preflightPrompter. It reads from
Expand Down Expand Up @@ -68,6 +72,30 @@ func (p *stdinPrompter) Confirm(message string, defaultYes bool) (bool, error) {
return response == "y" || response == "yes", nil
}

// Ask reads a line of plain text, falling back to defaultValue on an empty
// line. Echo stays on — this is for non-secret input such as trigger lists.
func (p *stdinPrompter) Ask(message, defaultValue string) (string, error) {
prompt := message
if defaultValue != "" {
prompt = fmt.Sprintf("%s [%s]", message, defaultValue)
}
if _, err := fmt.Fprint(p.out, prompt+": "); err != nil {
return "", err
}
line, err := p.in.ReadString('\n')
if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" {
return "", io.EOF
}
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
value := strings.TrimSpace(line)
if value == "" {
return defaultValue, nil
}
return value, nil
}

// Password prompts for a secret with terminal echo disabled when stdin
// is a TTY. When stdin is not a TTY (tests, pipes), it reads a line in
// the clear — echo doesn't matter in those contexts.
Expand Down
Loading
Loading