molecule-core/workspace-server/internal/handlers/config.go
Hongming Wang 60c4801a13 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) <noreply@anthropic.com>
2026-04-19 01:23:03 -07:00

75 lines
2.0 KiB
Go

package handlers
import (
"database/sql"
"encoding/json"
"io"
"log"
"net/http"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
type ConfigHandler struct{}
func NewConfigHandler() *ConfigHandler { return &ConfigHandler{} }
// Get handles GET /workspaces/:id/config
func (h *ConfigHandler) Get(c *gin.Context) {
workspaceID := c.Param("id")
var data []byte
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT data FROM workspace_config WHERE workspace_id = $1`,
workspaceID,
).Scan(&data)
if err == sql.ErrNoRows {
c.JSON(http.StatusOK, gin.H{"data": json.RawMessage("{}")})
return
}
if err != nil {
log.Printf("Config get error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(data)})
}
// Patch handles PATCH /workspaces/:id/config
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.StatusRequestEntityTooLarge, gin.H{"error": "body too large or unreadable"})
return
}
if !json.Valid(body) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
_, err = db.DB.ExecContext(c.Request.Context(), `
INSERT INTO workspace_config(workspace_id, data, updated_at)
VALUES($1, $2::jsonb, NOW())
ON CONFLICT(workspace_id) DO UPDATE
SET data = workspace_config.data || $2::jsonb, updated_at = NOW()
`, workspaceID, string(body))
if err != nil {
log.Printf("Config patch error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}