Skip to content

Commit 0ceb973

Browse files
hallelx2claude
andcommitted
feat(queue): verify QStash webhooks before dispatch
Implements Upstash signature verification in Go (HS256 JWT, current + next signing keys for rotation, exp/nbf/body/sub checks). The dashboard's Node `Receiver.verify()` from `@upstash/qstash` was the reference — we mirror the same claim set and header layout so both services accept the same signed deliveries. Wires the verifier into `POST /internal/jobs/{kind}`: reads the raw body, verifies `Upstash-Signature` against the configured keys and the canonical webhook URL, then dispatches through the existing `QStash.Dispatch` handler registry. Accepts both full `Job` envelopes (what the engine's own Enqueue emits) and bare payloads (what the dashboard's `publishJSON` emits). Config now surfaces `current_signing_key` / `next_signing_key` under `queue.qstash`, with env-var overrides that accept both VLE_-prefixed and bare names (QSTASH_CURRENT_SIGNING_KEY etc.) so ops can share env with the dashboard. Unit tests cover happy path, next-key rotation, wrong key, tampered body, expired exp, wrong sub, malformed JWT, and URL-check-skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6ed9029 commit 0ceb973

6 files changed

Lines changed: 529 additions & 9 deletions

File tree

cmd/engine/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,10 @@ func buildQueue(c config.QueueConfig, dbURL string) (queue.Queue, error) {
180180
switch c.Driver {
181181
case "qstash":
182182
return queue.NewQStash(queue.QStashConfig{
183-
Token: c.QStash.Token,
184-
WebhookBaseURL: c.QStash.WebhookBaseURL,
183+
Token: c.QStash.Token,
184+
WebhookBaseURL: c.QStash.WebhookBaseURL,
185+
CurrentSigningKey: c.QStash.CurrentSigningKey,
186+
NextSigningKey: c.QStash.NextSigningKey,
185187
})
186188
case "river":
187189
return queue.NewRiver(queue.RiverConfig{

internal/api/server.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,90 @@ func (d Deps) handleQuery(w http.ResponseWriter, r *http.Request) {
419419

420420
// --- internal queue webhook ---
421421

422+
// handleQueueWebhook is the endpoint QStash POSTs to. It verifies the
423+
// Upstash-Signature header, then dispatches the decoded payload into the
424+
// queue handler registered for {kind}.
425+
//
426+
// Only wired up when the configured queue is *queue.QStash; with other
427+
// drivers (River, Asynq) the route is present but returns 404-ish: there
428+
// is no webhook consumer to run.
422429
func (d Deps) handleQueueWebhook(w http.ResponseWriter, r *http.Request) {
423-
// TODO(phase-1): verify QStash signature, decode body, dispatch.
430+
qq, ok := d.Queue.(*queue.QStash)
431+
if !ok {
432+
writeErr(w, http.StatusNotFound, "webhook not enabled: queue driver is not qstash")
433+
return
434+
}
435+
436+
kind := queue.JobKind(chi.URLParam(r, "kind"))
437+
if kind == "" {
438+
writeErr(w, http.StatusBadRequest, "missing kind")
439+
return
440+
}
441+
442+
// Read the full body up front — verification hashes it, and the
443+
// handler needs the raw bytes too.
444+
body, err := io.ReadAll(r.Body)
445+
if err != nil {
446+
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
447+
return
448+
}
449+
_ = r.Body.Close()
450+
451+
// Signature check. If no verifier is configured we refuse to proceed:
452+
// an unauthenticated webhook endpoint that executes jobs is an open
453+
// door to the worker. Local-only dev can set VLE_QSTASH_CURRENT_SIGNING_KEY
454+
// to any string and sign test requests with it.
455+
v := qq.Verifier()
456+
if v == nil {
457+
writeErr(w, http.StatusUnauthorized, "qstash signing key not configured")
458+
return
459+
}
460+
461+
// Reconstruct the URL QStash signed against. We prefer the configured
462+
// WebhookBaseURL over r.Host — behind TLS terminators r.TLS and
463+
// r.Host are unreliable, and the operator already told us the
464+
// canonical external URL at boot.
465+
expectedURL := strings.TrimRight(qq.WebhookBaseURL(), "/") + r.URL.Path
466+
467+
sig := r.Header.Get("Upstash-Signature")
468+
if err := v.Verify(sig, body, expectedURL); err != nil {
469+
if d.Logger != nil {
470+
d.Logger.Warn("qstash verify failed", "err", err, "kind", kind)
471+
}
472+
writeErr(w, http.StatusUnauthorized, "invalid qstash signature")
473+
return
474+
}
475+
476+
// Body shape: either a full queue.Job (has `kind` + `payload`) or the
477+
// bare payload (`kind` is already in the URL). Accept both — the
478+
// dashboard publishes bare payloads today; the engine's own Enqueue
479+
// publishes full Jobs.
480+
payload := body
481+
var maybe struct {
482+
Kind queue.JobKind `json:"kind"`
483+
Payload json.RawMessage `json:"payload"`
484+
}
485+
if err := json.Unmarshal(body, &maybe); err == nil && maybe.Kind != "" && len(maybe.Payload) > 0 {
486+
if maybe.Kind != kind {
487+
writeErr(w, http.StatusBadRequest, "kind in body does not match URL")
488+
return
489+
}
490+
payload = maybe.Payload
491+
}
492+
493+
if err := qq.Dispatch(r.Context(), kind, payload); err != nil {
494+
if errors.Is(err, queue.ErrUnknownKind) {
495+
writeErr(w, http.StatusNotFound, err.Error())
496+
return
497+
}
498+
if d.Logger != nil {
499+
d.Logger.Error("qstash dispatch failed", "err", err, "kind", kind)
500+
}
501+
// 5xx → QStash will retry per the publish-time Upstash-Retries header.
502+
writeErr(w, http.StatusInternalServerError, err.Error())
503+
return
504+
}
505+
424506
w.WriteHeader(http.StatusNoContent)
425507
}
426508

pkg/config/config.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,16 @@ type QueueConfig struct {
9292
}
9393

9494
// QStashBlock configures QStash.
95+
//
96+
// Token is the publish token (used to enqueue). CurrentSigningKey and
97+
// NextSigningKey are used to verify inbound webhooks; both are surfaced
98+
// on the Upstash console under "Signing Keys". NextSigningKey is only
99+
// populated while rotating.
95100
type QStashBlock struct {
96-
Token string `yaml:"token"`
97-
WebhookBaseURL string `yaml:"webhook_base_url"`
101+
Token string `yaml:"token"`
102+
WebhookBaseURL string `yaml:"webhook_base_url"`
103+
CurrentSigningKey string `yaml:"current_signing_key"`
104+
NextSigningKey string `yaml:"next_signing_key"`
98105
}
99106

100107
// RiverBlock configures River.
@@ -209,6 +216,17 @@ func Load(path string) (Config, error) {
209216
return cfg, nil
210217
}
211218

219+
// firstEnv returns the first non-empty environment variable value from
220+
// the supplied names, checked in order.
221+
func firstEnv(names ...string) string {
222+
for _, n := range names {
223+
if v := os.Getenv(n); v != "" {
224+
return v
225+
}
226+
}
227+
return ""
228+
}
229+
212230
func applyEnvOverrides(c *Config) {
213231
// Minimal, deliberately shallow env handling — production-heavy values
214232
// that are typically rotated live here. Extend as needed.
@@ -230,12 +248,22 @@ func applyEnvOverrides(c *Config) {
230248
if v := os.Getenv("VLE_GEMINI_API_KEY"); v != "" {
231249
c.LLM.Gemini.APIKey = v
232250
}
233-
if v := os.Getenv("VLE_QSTASH_TOKEN"); v != "" {
251+
// Accept both VLE_-prefixed and bare QSTASH_* env vars. The bare
252+
// names match what the Upstash console documents and what the
253+
// dashboard already uses, so ops can set them once for both
254+
// services. VLE_-prefixed wins if both are set.
255+
if v := firstEnv("VLE_QSTASH_TOKEN", "QSTASH_TOKEN"); v != "" {
234256
c.Queue.QStash.Token = v
235257
}
236-
if v := os.Getenv("VLE_QSTASH_WEBHOOK_BASE_URL"); v != "" {
258+
if v := firstEnv("VLE_QSTASH_WEBHOOK_BASE_URL", "QSTASH_WEBHOOK_BASE_URL"); v != "" {
237259
c.Queue.QStash.WebhookBaseURL = v
238260
}
261+
if v := firstEnv("VLE_QSTASH_CURRENT_SIGNING_KEY", "QSTASH_CURRENT_SIGNING_KEY"); v != "" {
262+
c.Queue.QStash.CurrentSigningKey = v
263+
}
264+
if v := firstEnv("VLE_QSTASH_NEXT_SIGNING_KEY", "QSTASH_NEXT_SIGNING_KEY"); v != "" {
265+
c.Queue.QStash.NextSigningKey = v
266+
}
239267
if v := os.Getenv("VLE_STORAGE_DRIVER"); v != "" {
240268
c.Storage.Driver = v
241269
}
@@ -273,6 +301,12 @@ func (c Config) Validate() error {
273301
if c.Queue.QStash.Token == "" {
274302
return errors.New("queue.qstash.token is required when driver=qstash")
275303
}
304+
if c.Queue.QStash.WebhookBaseURL == "" {
305+
return errors.New("queue.qstash.webhook_base_url is required when driver=qstash")
306+
}
307+
if c.Queue.QStash.CurrentSigningKey == "" {
308+
return errors.New("queue.qstash.current_signing_key is required when driver=qstash")
309+
}
276310
case "river":
277311
if c.Database.URL == "" {
278312
return errors.New("database.url is required when queue.driver=river")

pkg/queue/qstash.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ type QStashConfig struct {
2121
// will POST jobs to WebhookBaseURL + "/internal/jobs/:kind".
2222
WebhookBaseURL string
2323

24+
// CurrentSigningKey / NextSigningKey are the QStash webhook signing
25+
// keys used to verify inbound deliveries. Both are surfaced on the
26+
// Upstash console; the "next" key is only populated during key
27+
// rotation. Required when the engine is the webhook consumer; unused
28+
// by Enqueue.
29+
CurrentSigningKey string
30+
NextSigningKey string
31+
2432
// HTTPClient is optional; if nil, http.DefaultClient is used.
2533
HTTPClient *http.Client
2634
}
@@ -33,11 +41,17 @@ type QStashConfig struct {
3341
type QStash struct {
3442
cfg QStashConfig
3543
client *http.Client
44+
verifier *Verifier
3645
mu sync.RWMutex
3746
handlers map[JobKind]Handler
3847
}
3948

4049
// NewQStash constructs a new QStash-backed Queue.
50+
//
51+
// If CurrentSigningKey is set, a Verifier is constructed and made
52+
// available via Verifier(); callers should apply it to the webhook
53+
// route. Without a signing key the engine will still accept callbacks
54+
// (useful for local dev) but log a loud warning elsewhere.
4155
func NewQStash(cfg QStashConfig) (*QStash, error) {
4256
if cfg.Token == "" {
4357
return nil, errors.New("qstash: token is required")
@@ -49,13 +63,33 @@ func NewQStash(cfg QStashConfig) (*QStash, error) {
4963
if client == nil {
5064
client = &http.Client{Timeout: 15 * time.Second}
5165
}
52-
return &QStash{
66+
q := &QStash{
5367
cfg: cfg,
5468
client: client,
5569
handlers: map[JobKind]Handler{},
56-
}, nil
70+
}
71+
if cfg.CurrentSigningKey != "" {
72+
v, err := NewVerifier(VerifierConfig{
73+
CurrentSigningKey: cfg.CurrentSigningKey,
74+
NextSigningKey: cfg.NextSigningKey,
75+
})
76+
if err != nil {
77+
return nil, err
78+
}
79+
q.verifier = v
80+
}
81+
return q, nil
5782
}
5883

84+
// Verifier returns the configured signature verifier, or nil if no
85+
// signing key was supplied. The webhook handler uses this to reject
86+
// unsigned or forged deliveries.
87+
func (q *QStash) Verifier() *Verifier { return q.verifier }
88+
89+
// WebhookBaseURL exposes the configured base URL so the webhook handler
90+
// can reconstruct the `sub` claim it expects.
91+
func (q *QStash) WebhookBaseURL() string { return q.cfg.WebhookBaseURL }
92+
5993
func (q *QStash) Register(kind JobKind, h Handler) {
6094
q.mu.Lock()
6195
defer q.mu.Unlock()

0 commit comments

Comments
 (0)