Skip to content

Commit e74c0e6

Browse files
sec: payload validation + reader/writer split + startup-delete (#28)
* sec: validate payload, split reader/writer, add startup-delete Addresses three findings from the post-deploy security audit: C3 (handlers): the previous `0x1f 0x8b` magic-byte check let an attacker store any garbage prefixed with the gzip magic, which then crashed every viewer. handleCreate now decompresses under a hard cap (default 4 MiB, --max-inflated-bytes), unmarshals into share.Payload, and rejects anything whose v != 1 or that fails to parse. Closes the gzip-bomb-via-server vector too. H2 (store): writer and reader connection pools opened separately against the same file. Writer keeps MaxOpenConns(1); reader uses an 8-slot pool with ?mode=ro. A long write no longer stalls GETs. Added an index on created_at so the retention sweep stops table- scanning. Operations (store + main): - DeleteByCode method for removing a single payload. - --delete-on-startup flag (comma-separated codes) wired in main.go, used by operators to take down a malicious entry on the next deploy without needing kubectl exec into the pod. - --max-inflated-bytes flag exposes the new cap. Tests: - TestCreateRejectsGzipMagicWithGarbage - TestCreateRejectsGzipBomb (cap honoured under attack) - TestCreateRejectsWrongSchemaVersion - TestCreateRejectsNotJSON - TestCreateAcceptsValidPayload (positive control) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * sec: address review feedback on validation + lifecycle - validatePayload: stream-decode JSON directly off the gzip+LimitReader pipeline instead of buffering the whole inflated payload in memory. Drops the transient ~2x cap allocation that io.ReadAll caused on every POST. Trailing bytes past the cap are drained and rejected. - Store.Close: join reader and writer close errors via errors.Join so neither one disappears during a restart loop. - startupDelete: validate each comma-separated entry with shareapi.ValidCode before issuing DELETE, so operator typos surface immediately and arbitrary log content cannot reach the logger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: dmitriimaksimovdevelop <227611064+dmitriimaksimovdevelop@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0bd5357 commit e74c0e6

4 files changed

Lines changed: 282 additions & 39 deletions

File tree

cmd/melisai-share-api/main.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"os"
2121
"os/signal"
2222
"path/filepath"
23+
"strings"
2324
"syscall"
2425
"time"
2526

@@ -28,13 +29,15 @@ import (
2829

2930
func main() {
3031
var (
31-
addr = flag.String("addr", ":8080", "HTTP listen address")
32-
dbPath = flag.String("db", "/var/lib/melisai-share-api/store.db", "SQLite database path")
33-
publicBase = flag.String("public-base", "https://melisai.dev/r", "Viewer base URL embedded in POST responses")
34-
maxBodyBytes = flag.Int64("max-body-bytes", shareapi.DefaultMaxBodyBytes, "Max upload size in bytes")
35-
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, error")
36-
retention = flag.Duration("retention", 0, "Delete reports older than this; 0 disables retention (e.g. 2160h for 90 days)")
37-
cleanupInterval = flag.Duration("cleanup-interval", time.Hour, "How often the retention sweep runs (ignored when --retention=0)")
32+
addr = flag.String("addr", ":8080", "HTTP listen address")
33+
dbPath = flag.String("db", "/var/lib/melisai-share-api/store.db", "SQLite database path")
34+
publicBase = flag.String("public-base", "https://melisai.dev/r", "Viewer base URL embedded in POST responses")
35+
maxBodyBytes = flag.Int64("max-body-bytes", shareapi.DefaultMaxBodyBytes, "Max compressed upload size in bytes")
36+
maxInflatedBytes = flag.Int64("max-inflated-bytes", shareapi.DefaultMaxInflatedBytes, "Max decompressed payload size in bytes (defends against gzip bombs)")
37+
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, error")
38+
retention = flag.Duration("retention", 0, "Delete reports older than this; 0 disables retention (e.g. 2160h for 90 days)")
39+
cleanupInterval = flag.Duration("cleanup-interval", time.Hour, "How often the retention sweep runs (ignored when --retention=0)")
40+
deleteOnStartup = flag.String("delete-on-startup", "", "Comma-separated codes to delete from the store at startup, then continue normally")
3841
)
3942
flag.Parse()
4043

@@ -45,13 +48,13 @@ func main() {
4548
os.Exit(2)
4649
}
4750

48-
if err := run(*addr, *dbPath, *publicBase, *maxBodyBytes, *retention, *cleanupInterval, logger); err != nil {
51+
if err := run(*addr, *dbPath, *publicBase, *maxBodyBytes, *maxInflatedBytes, *retention, *cleanupInterval, *deleteOnStartup, logger); err != nil {
4952
logger.Error("server failed", "err", err)
5053
os.Exit(1)
5154
}
5255
}
5356

54-
func run(addr, dbPath, publicBase string, maxBodyBytes int64, retention, cleanupInterval time.Duration, log *slog.Logger) error {
57+
func run(addr, dbPath, publicBase string, maxBodyBytes, maxInflatedBytes int64, retention, cleanupInterval time.Duration, deleteOnStartup string, log *slog.Logger) error {
5558
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
5659
return fmt.Errorf("create db dir: %w", err)
5760
}
@@ -66,10 +69,17 @@ func run(addr, dbPath, publicBase string, maxBodyBytes int64, retention, cleanup
6669
}
6770
}()
6871

72+
if deleteOnStartup != "" {
73+
if err := startupDelete(context.Background(), store, deleteOnStartup, log); err != nil {
74+
return fmt.Errorf("delete-on-startup: %w", err)
75+
}
76+
}
77+
6978
srv, err := shareapi.NewServer(store, publicBase, maxBodyBytes, log)
7079
if err != nil {
7180
return fmt.Errorf("init server: %w", err)
7281
}
82+
srv = srv.WithMaxInflatedBytes(maxInflatedBytes)
7383

7484
httpSrv := &http.Server{
7585
Addr: addr,
@@ -123,6 +133,33 @@ func run(addr, dbPath, publicBase string, maxBodyBytes int64, retention, cleanup
123133
return nil
124134
}
125135

136+
// startupDelete removes the given comma-separated codes from the store
137+
// once before the HTTP server starts accepting traffic. Used to take
138+
// down malicious or accidentally-shared payloads without invoking
139+
// kubectl exec — set --delete-on-startup=Xxxx,Yyyy on the next deploy,
140+
// observe the log line, then remove the flag.
141+
//
142+
// Codes are validated against the same shape as GenerateCode produces,
143+
// so an operator typo cannot end up logging arbitrary control bytes
144+
// or silently no-op'ing a delete against a malformed code.
145+
func startupDelete(ctx context.Context, store *shareapi.Store, codes string, log *slog.Logger) error {
146+
for _, code := range strings.Split(codes, ",") {
147+
code = strings.TrimSpace(code)
148+
if code == "" {
149+
continue
150+
}
151+
if !shareapi.ValidCode(code) {
152+
return fmt.Errorf("invalid code %q: must be %d base62 characters", code, shareapi.CodeLength)
153+
}
154+
n, err := store.DeleteByCode(ctx, code)
155+
if err != nil {
156+
return fmt.Errorf("delete %q: %w", code, err)
157+
}
158+
log.Info("delete-on-startup", "code", code, "rows", n)
159+
}
160+
return nil
161+
}
162+
126163
// runRetention drops rows older than `age` on the given interval until
127164
// ctx is cancelled. The first sweep fires immediately on startup to
128165
// shrink any backlog from a previous run with longer retention.

internal/shareapi/handlers.go

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package shareapi
22

33
import (
4+
"bytes"
5+
"compress/gzip"
46
"context"
57
"encoding/json"
68
"errors"
@@ -10,13 +12,22 @@ import (
1012
"net/http"
1113
"net/url"
1214
"strings"
15+
16+
"github.com/dmitriimaksimovdevelop/melisai/internal/share"
1317
)
1418

15-
// DefaultMaxBodyBytes caps the size of an uploaded payload at 1 MB.
16-
// melisai's gzip-compressed summary is ~1–10 KB in practice; 1 MB
17-
// leaves a generous safety margin without making bombing easy.
19+
// DefaultMaxBodyBytes caps the size of an uploaded *compressed* payload
20+
// at 1 MB. melisai's gzip-compressed summary is ~1–10 KB in practice;
21+
// 1 MB leaves a generous safety margin without making bombing easy.
1822
const DefaultMaxBodyBytes = 1 << 20
1923

24+
// DefaultMaxInflatedBytes caps the *decompressed* size we are willing
25+
// to accept and store. 4 MiB is ~160x a realistic standard-profile
26+
// summary; anything larger is almost certainly a gzip bomb. Without
27+
// this cap a 1 MB POST can store bytes that fan out to 1 GB on the
28+
// viewer (max DEFLATE ratio ≈ 1032×), OOMing every visitor's browser.
29+
const DefaultMaxInflatedBytes = 4 << 20
30+
2031
// retryOnCollision is how many fresh codes we try before giving up on
2132
// an insert. With 62^8 ≈ 2×10^14 slots, three retries is already
2233
// extraordinary luck running out — anything beyond hints at storage
@@ -25,10 +36,11 @@ const retryOnCollision = 3
2536

2637
// Server is the HTTP layer.
2738
type Server struct {
28-
store *Store
29-
publicBase string // e.g. "https://melisai.dev/r" — used to build the URL returned by POST
30-
maxBodyBytes int64
31-
log *slog.Logger
39+
store *Store
40+
publicBase string // e.g. "https://melisai.dev/r" — used to build the URL returned by POST
41+
maxBodyBytes int64
42+
maxInflatedBytes int64
43+
log *slog.Logger
3244
}
3345

3446
// NewServer wires the dependencies. publicBase must be a valid http(s)
@@ -46,13 +58,23 @@ func NewServer(store *Store, publicBase string, maxBodyBytes int64, log *slog.Lo
4658
log = slog.Default()
4759
}
4860
return &Server{
49-
store: store,
50-
publicBase: strings.TrimRight(publicBase, "/"),
51-
maxBodyBytes: maxBodyBytes,
52-
log: log,
61+
store: store,
62+
publicBase: strings.TrimRight(publicBase, "/"),
63+
maxBodyBytes: maxBodyBytes,
64+
maxInflatedBytes: DefaultMaxInflatedBytes,
65+
log: log,
5366
}, nil
5467
}
5568

69+
// WithMaxInflatedBytes overrides the decompressed-size cap. Pass <=0
70+
// to keep the default. Returned for fluent configuration in main.go.
71+
func (s *Server) WithMaxInflatedBytes(n int64) *Server {
72+
if n > 0 {
73+
s.maxInflatedBytes = n
74+
}
75+
return s
76+
}
77+
5678
// Handler returns the HTTP handler covering /healthz and /api/r.
5779
// Mount this directly on a net/http server.
5880
func (s *Server) Handler() http.Handler {
@@ -95,8 +117,9 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
95117
}
96118

97119
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
98-
// Cap the body before anything else — http.MaxBytesReader returns a
99-
// typed error after the limit so the read fails fast on huge uploads.
120+
// Cap the compressed body before anything else — http.MaxBytesReader
121+
// returns a typed error after the limit so the read fails fast on
122+
// huge uploads.
100123
r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes)
101124
defer r.Body.Close()
102125

@@ -115,13 +138,24 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
115138
writeError(w, http.StatusBadRequest, "empty body")
116139
return
117140
}
118-
// Sanity-check the gzip magic so we reject obvious junk upfront.
119-
// The viewer page assumes gzip; storing anything else is a footgun.
141+
// Sanity-check the gzip magic so we reject obvious junk upfront
142+
// cheap fast-path before allocating a gzip.Reader.
120143
if len(payload) < 2 || payload[0] != 0x1f || payload[1] != 0x8b {
121144
writeError(w, http.StatusBadRequest, "body must be gzip-compressed")
122145
return
123146
}
124147

148+
// Decompress under a hard size cap, decode as a typed Payload, and
149+
// reject anything we cannot serve back to a viewer. This is the
150+
// defense against:
151+
// * gzip bombs (1 MB POST → 1 GB inflate → browser OOM on /r/{code})
152+
// * gzip-magic-prefixed garbage that stores fine but breaks viewers
153+
// * future schema bumps stored against old viewers (v != 1)
154+
if err := s.validatePayload(payload); err != nil {
155+
writeError(w, http.StatusBadRequest, err.Error())
156+
return
157+
}
158+
125159
code, err := s.putWithRetry(r.Context(), payload)
126160
if err != nil {
127161
s.log.Error("create: store put failed", "err", err)
@@ -135,6 +169,48 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
135169
_ = json.NewEncoder(w).Encode(resp)
136170
}
137171

172+
// validatePayload inflates payload under a size cap and confirms it
173+
// parses as a current-schema share.Payload. It returns a user-facing
174+
// error message on rejection.
175+
//
176+
// We stream-decode JSON directly off the gzip reader (wrapped in a
177+
// LimitReader) rather than holding the whole inflated buffer in
178+
// memory. This keeps peak transient allocation bounded by json's
179+
// internal buffer plus the unmarshaled struct, instead of 2× the cap
180+
// that io.ReadAll would temporarily reserve.
181+
func (s *Server) validatePayload(payload []byte) error {
182+
gz, err := gzip.NewReader(bytes.NewReader(payload))
183+
if err != nil {
184+
return fmt.Errorf("payload is not valid gzip: %w", err)
185+
}
186+
defer gz.Close()
187+
188+
// We accept unknown JSON fields on purpose: future v=1-compatible
189+
// clients may add optional fields (e.g. an extra meta.* tag); the
190+
// viewer already ignores them, so the server should too.
191+
limited := io.LimitReader(gz, s.maxInflatedBytes+1)
192+
var parsed share.Payload
193+
if err := json.NewDecoder(limited).Decode(&parsed); err != nil {
194+
return fmt.Errorf("payload is not valid melisai share JSON: %w", err)
195+
}
196+
// After Decode succeeds, drain the rest under the same cap. If any
197+
// bytes remain past maxInflatedBytes we know the payload was a
198+
// bomb that happened to be valid JSON in its prefix — reject it.
199+
overflow, err := io.Copy(io.Discard, limited)
200+
if err != nil {
201+
return fmt.Errorf("read trailing payload: %w", err)
202+
}
203+
if overflow > 0 {
204+
// Decode advanced exactly maxInflatedBytes — anything left in
205+
// the gzip stream past the cap is over-budget.
206+
return fmt.Errorf("decompressed payload exceeds %d bytes (likely a gzip bomb)", s.maxInflatedBytes)
207+
}
208+
if parsed.V != share.PayloadVersion {
209+
return fmt.Errorf("unsupported payload version %d (server expects %d)", parsed.V, share.PayloadVersion)
210+
}
211+
return nil
212+
}
213+
138214
func (s *Server) handleFetch(w http.ResponseWriter, r *http.Request) {
139215
code := r.PathValue("code")
140216
if !ValidCode(code) {

internal/shareapi/handlers_test.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ func gzipBytes(t *testing.T, data string) []byte {
2424
return buf.Bytes()
2525
}
2626

27+
// validPayloadGzip returns the minimum payload accepted by handleCreate
28+
// after the C3 validation: schema v=1, parses into share.Payload.
29+
func validPayloadGzip(t *testing.T) []byte {
30+
return gzipBytes(t, `{"v":1,"meta":{"hostname":"h","kernel":"k","cpus":1,"memory_gb":1,"profile":"quick","duration":"10s","timestamp":"t","tool_version":"v"},"summary":{"health_score":100,"anomalies":[],"resources":{},"recommendations":[]}}`)
31+
}
32+
2733
func newTestServer(t *testing.T) (*Server, *httptest.Server) {
2834
t.Helper()
2935
store := openTestStore(t)
@@ -38,7 +44,7 @@ func newTestServer(t *testing.T) (*Server, *httptest.Server) {
3844

3945
func TestCreateAndFetchRoundTrip(t *testing.T) {
4046
_, ts := newTestServer(t)
41-
payload := gzipBytes(t, `{"v":1,"hello":"world"}`)
47+
payload := validPayloadGzip(t)
4248

4349
// POST
4450
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(payload))
@@ -97,6 +103,82 @@ func TestCreateRejectsNonGzip(t *testing.T) {
97103
}
98104
}
99105

106+
// TestCreateRejectsGzipMagicWithGarbage covers the C3 finding from the
107+
// security audit: previously the handler only verified the two gzip
108+
// magic bytes, so a payload starting with 1f 8b followed by random
109+
// data was happily stored and later crashed every viewer.
110+
func TestCreateRejectsGzipMagicWithGarbage(t *testing.T) {
111+
_, ts := newTestServer(t)
112+
payload := append([]byte{0x1f, 0x8b}, []byte("not a real gzip stream")...)
113+
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(payload))
114+
if err != nil {
115+
t.Fatalf("POST: %v", err)
116+
}
117+
if resp.StatusCode != http.StatusBadRequest {
118+
t.Errorf("status = %d, want 400", resp.StatusCode)
119+
}
120+
}
121+
122+
func TestCreateRejectsGzipBomb(t *testing.T) {
123+
store := openTestStore(t)
124+
srv, err := NewServer(store, "https://example.test/r", 0, nil)
125+
if err != nil {
126+
t.Fatalf("NewServer: %v", err)
127+
}
128+
srv = srv.WithMaxInflatedBytes(1024) // 1 KiB cap for this test
129+
ts := httptest.NewServer(srv.Handler())
130+
defer ts.Close()
131+
132+
// Compress 16 KiB of zeros — gzip squeezes this to <100 bytes but
133+
// inflates back to 16 KiB, well over our test cap.
134+
bomb := gzipBytes(t, strings.Repeat("\x00", 16*1024))
135+
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(bomb))
136+
if err != nil {
137+
t.Fatalf("POST: %v", err)
138+
}
139+
if resp.StatusCode != http.StatusBadRequest {
140+
body, _ := io.ReadAll(resp.Body)
141+
t.Errorf("bomb status = %d, want 400, body=%s", resp.StatusCode, body)
142+
}
143+
}
144+
145+
func TestCreateRejectsWrongSchemaVersion(t *testing.T) {
146+
_, ts := newTestServer(t)
147+
body := gzipBytes(t, `{"v":99,"meta":{},"summary":{}}`)
148+
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(body))
149+
if err != nil {
150+
t.Fatalf("POST: %v", err)
151+
}
152+
if resp.StatusCode != http.StatusBadRequest {
153+
t.Errorf("status = %d, want 400 (unsupported version)", resp.StatusCode)
154+
}
155+
}
156+
157+
func TestCreateRejectsNotJSON(t *testing.T) {
158+
_, ts := newTestServer(t)
159+
body := gzipBytes(t, "this is not json at all, just some text")
160+
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(body))
161+
if err != nil {
162+
t.Fatalf("POST: %v", err)
163+
}
164+
if resp.StatusCode != http.StatusBadRequest {
165+
t.Errorf("status = %d, want 400", resp.StatusCode)
166+
}
167+
}
168+
169+
func TestCreateAcceptsValidPayload(t *testing.T) {
170+
_, ts := newTestServer(t)
171+
body := gzipBytes(t, `{"v":1,"meta":{"hostname":"h","kernel":"k","cpus":1,"memory_gb":1,"profile":"quick","duration":"10s","timestamp":"now","tool_version":"0.0.0"},"summary":{"health_score":100,"anomalies":[],"resources":{},"recommendations":[]}}`)
172+
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream", bytes.NewReader(body))
173+
if err != nil {
174+
t.Fatalf("POST: %v", err)
175+
}
176+
if resp.StatusCode != http.StatusCreated {
177+
b, _ := io.ReadAll(resp.Body)
178+
t.Errorf("status = %d, want 201, body=%s", resp.StatusCode, b)
179+
}
180+
}
181+
100182
func TestCreateRejectsEmpty(t *testing.T) {
101183
_, ts := newTestServer(t)
102184
resp, err := http.Post(ts.URL+"/api/r", "application/octet-stream",

0 commit comments

Comments
 (0)