From 60c4801a13c1fe721c53746afa9886cd106ef87e Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 19 Apr 2026 01:23:03 -0700 Subject: [PATCH] fix(security): cap webhook + config PATCH bodies (H3/H4) Two HIGH-severity DoS surfaces: both handlers read the entire HTTP body with io.ReadAll(r.Body) and no upper bound, so a caller streaming a multi-gigabyte request could exhaust memory on the tenant instance before we even validated the JSON. H3 (Discord webhook): wrap Body in io.LimitReader with a 1 MiB cap. Discord Interactions payloads are well under 10 KiB in practice. H4 (workspace config PATCH): wrap Body in http.MaxBytesReader with a 256 KiB cap. Real configs are <10 KiB; jsonb handles the cap comfortably. Returns 413 Request Entity Too Large on overflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace-server/internal/channels/discord.go | 6 +++++- workspace-server/internal/handlers/config.go | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/workspace-server/internal/channels/discord.go b/workspace-server/internal/channels/discord.go index e640e20f..965313f8 100644 --- a/workspace-server/internal/channels/discord.go +++ b/workspace-server/internal/channels/discord.go @@ -106,7 +106,11 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte // Returns nil, nil for PING payloads — the handler layer must respond with `{"type":1}` to pass // Discord's endpoint verification. Returns an InboundMessage for APPLICATION_COMMAND payloads. func (d *DiscordAdapter) ParseWebhook(c *gin.Context, _ map[string]interface{}) (*InboundMessage, error) { - body, err := io.ReadAll(c.Request.Body) + // Cap incoming webhook bodies at 1 MiB. Discord's Interactions API + // payloads are well under 10 KiB in practice; the cap is a DoS + // guard, not a functional limit. + const maxDiscordWebhook = 1 << 20 + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDiscordWebhook)) if err != nil { return nil, fmt.Errorf("discord: read body: %w", err) } diff --git a/workspace-server/internal/handlers/config.go b/workspace-server/internal/handlers/config.go index 9214be5c..fa83b98b 100644 --- a/workspace-server/internal/handlers/config.go +++ b/workspace-server/internal/handlers/config.go @@ -42,9 +42,14 @@ func (h *ConfigHandler) Get(c *gin.Context) { func (h *ConfigHandler) Patch(c *gin.Context) { workspaceID := c.Param("id") + // 256 KiB cap: Postgres jsonb comfortably handles this and real + // configs are <10 KiB. The cap blocks naive memory-exhaustion DoS + // — a caller streaming a gigabyte of JSON would OOM the instance. + const maxConfigBody = 256 << 10 + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxConfigBody) body, err := io.ReadAll(c.Request.Body) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "body too large or unreadable"}) return }