Skip to content

Commit 404718c

Browse files
committed
refactor: stream base64 attachment decoding and enforce request body size limits
1 parent 474d06d commit 404718c

6 files changed

Lines changed: 64 additions & 69 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ toolchain go1.25.9
77
require (
88
github.com/go-chi/chi/v5 v5.2.5
99
github.com/go-playground/validator/v10 v10.30.2
10+
github.com/golang-jwt/jwt/v5 v5.3.1
1011
github.com/google/uuid v1.6.0
1112
github.com/graph-gophers/graphql-go v1.9.0
1213
github.com/nats-io/nats.go v1.51.0
@@ -24,7 +25,6 @@ require (
2425
github.com/go-playground/locales v0.14.1 // indirect
2526
github.com/go-playground/universal-translator v0.18.1 // indirect
2627
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
27-
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
2828
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
2929
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3030
github.com/klauspost/compress v1.18.5 // indirect

internal/gateway/attachstore.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package gateway
22

33
import (
4-
"bytes"
54
"context"
5+
"encoding/base64"
66
"fmt"
77
"strconv"
8+
"strings"
89

910
"github.com/nats-io/nats.go"
1011

@@ -21,17 +22,18 @@ func NewAttachmentStore(store nats.ObjectStore) *AttachmentStore {
2122
return &AttachmentStore{store: store}
2223
}
2324

24-
func (a *AttachmentStore) Upload(ctx context.Context, traceID string, attachments []domain.AttachmentDO) ([]domain.AttachmentDO, error) {
25+
func (a *AttachmentStore) Upload(ctx context.Context, traceID string, attachments []domain.Attachment) ([]domain.AttachmentDO, error) {
2526
result := make([]domain.AttachmentDO, len(attachments))
2627
for i, att := range attachments {
2728
key := traceID + "/" + strconv.Itoa(i)
28-
_, err := a.store.Put(&nats.ObjectMeta{Name: key}, bytes.NewReader(att.Content))
29+
r := base64.NewDecoder(base64.StdEncoding, strings.NewReader(att.Content))
30+
_, err := a.store.Put(&nats.ObjectMeta{Name: key}, r)
2931
if err != nil {
3032
return nil, fmt.Errorf("object store put %s: %w", key, err)
3133
}
3234
result[i] = domain.AttachmentDO{
3335
Name: att.Name,
34-
ContentType: att.ContentType,
36+
ContentType: att.MimeType,
3537
ObjectKey: key,
3638
}
3739
}

internal/gateway/handler.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type natsPublisher interface {
3636
}
3737

3838
type attachmentUploader interface {
39-
Upload(ctx context.Context, traceID string, attachments []domain.AttachmentDO) ([]domain.AttachmentDO, error)
39+
Upload(ctx context.Context, traceID string, attachments []domain.Attachment) ([]domain.AttachmentDO, error)
4040
}
4141

4242
const (
@@ -72,9 +72,16 @@ func (h *Handler) handleSend(w http.ResponseWriter, r *http.Request) {
7272
traceID := uuid.New().String()
7373
ctx := r.Context()
7474

75+
r.Body = http.MaxBytesReader(w, r.Body, h.cfg.MaxBodySize)
7576
var req domain.MailRequest
7677
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
77-
writeError(w, http.StatusBadRequest, domain.ErrJSONParseError, "invalid JSON body", traceID)
78+
var maxBytesErr *http.MaxBytesError
79+
if errors.As(err, &maxBytesErr) {
80+
writeError(w, http.StatusRequestEntityTooLarge, domain.ErrBodyTooLarge,
81+
fmt.Sprintf("request body exceeds limit of %d bytes", h.cfg.MaxBodySize), traceID)
82+
} else {
83+
writeError(w, http.StatusBadRequest, domain.ErrJSONParseError, "invalid JSON body", traceID)
84+
}
7885
return
7986
}
8087

@@ -119,14 +126,10 @@ func (h *Handler) handleSend(w http.ResponseWriter, r *http.Request) {
119126
return
120127
}
121128

122-
// Decode attachments and upload to Object Store
123-
attachments, err := decodeAttachments(req.Attachments)
124-
if err != nil {
125-
h.writeValidationError(w, err, traceID)
126-
return
127-
}
128-
if len(attachments) > 0 {
129-
attachments, err = h.attStore.Upload(ctx, traceID, attachments)
129+
// Stage 6: upload attachments to Object Store (streaming base64 decode)
130+
var attachmentDOs []domain.AttachmentDO
131+
if len(req.Attachments) > 0 {
132+
attachmentDOs, err = h.attStore.Upload(ctx, traceID, req.Attachments)
130133
if err != nil {
131134
slog.ErrorContext(ctx, "attachment upload failed",
132135
slog.String("traceId", traceID),
@@ -148,7 +151,7 @@ func (h *Handler) handleSend(w http.ResponseWriter, r *http.Request) {
148151
Subject: req.Subject,
149152
BodyContent: req.BodyContent,
150153
HtmlBodyContent: req.HtmlBodyContent,
151-
Attachments: attachments,
154+
Attachments: attachmentDOs,
152155
TraceContext: req.TraceContext,
153156
Test: sender.Test,
154157
}

internal/gateway/handler_test.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"net/http"
99
"net/http/httptest"
10+
"strings"
1011
"testing"
1112

1213
"dispatch/internal/config"
@@ -42,13 +43,17 @@ func (s *stubPublisher) Publish(_ context.Context, _ *domain.MailRequestDO) erro
4243

4344
type stubAttStore struct{}
4445

45-
func (s *stubAttStore) Upload(_ context.Context, _ string, atts []domain.AttachmentDO) ([]domain.AttachmentDO, error) {
46-
return atts, nil
46+
func (s *stubAttStore) Upload(_ context.Context, _ string, atts []domain.Attachment) ([]domain.AttachmentDO, error) {
47+
result := make([]domain.AttachmentDO, len(atts))
48+
for i, a := range atts {
49+
result[i] = domain.AttachmentDO{Name: a.Name, ContentType: a.MimeType}
50+
}
51+
return result, nil
4752
}
4853

4954
type failAttStore struct{}
5055

51-
func (s *failAttStore) Upload(_ context.Context, _ string, _ []domain.AttachmentDO) ([]domain.AttachmentDO, error) {
56+
func (s *failAttStore) Upload(_ context.Context, _ string, _ []domain.Attachment) ([]domain.AttachmentDO, error) {
5257
return nil, errors.New("object store unavailable")
5358
}
5459

@@ -197,6 +202,20 @@ func TestHandleSend_AttachmentUploadError(t *testing.T) {
197202
}
198203
}
199204

205+
func TestHandleSend_BodyTooLarge(t *testing.T) {
206+
// MaxBodySize=10: any body > 10 bytes triggers MaxBytesError.
207+
// Body must start with valid JSON chars so the scanner doesn't fail first.
208+
cfg := config.Config{MaxBodySize: 10, MimeWhitelist: []string{}, MaxTotalAttachmentMB: 20}
209+
h := NewHandler(cfg, &stubSenders{sender: defaultSender()}, &stubQuota{}, &stubSpam{}, &stubPublisher{}, &stubAttStore{})
210+
body := `{"appTag":"test","bodyContent":"` + strings.Repeat("x", 100) + `"}`
211+
req := httptest.NewRequest(http.MethodPost, "/dispatch/api/v1/mail/send", strings.NewReader(body))
212+
rr := httptest.NewRecorder()
213+
h.Router().ServeHTTP(rr, req)
214+
if rr.Code != http.StatusRequestEntityTooLarge {
215+
t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String())
216+
}
217+
}
218+
200219
func TestHandleHealth(t *testing.T) {
201220
h := buildHandler(&stubSenders{sender: defaultSender()}, &stubQuota{}, &stubSpam{}, &stubPublisher{})
202221
req := httptest.NewRequest(http.MethodGet, "/health", nil)

internal/gateway/validation.go

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package gateway
33
import (
44
"encoding/base64"
55
"fmt"
6+
"io"
67
"strings"
78

89
"github.com/go-playground/validator/v10"
@@ -42,16 +43,22 @@ func validateRequest(req *domain.MailRequest, maxBodySize int64, mimeWhitelist [
4243
Message: fmt.Sprintf("MIME type not allowed: %s", a.MimeType),
4344
}
4445
}
45-
// base64 length × 3/4 ≈ raw bytes
46-
rawBytes := int64(len(a.Content)) * 3 / 4
47-
totalBytes += rawBytes
46+
r := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
47+
n, ioErr := io.Copy(io.Discard, r)
48+
if ioErr != nil {
49+
return &domain.ValidationError{
50+
Code: domain.ErrInvalidAttachmentType,
51+
Message: fmt.Sprintf("attachment %q: invalid base64", a.Name),
52+
}
53+
}
54+
totalBytes += n
4855
}
4956

5057
maxBytes := int64(maxAttachMB) * 1024 * 1024
5158
if totalBytes > maxBytes {
5259
return &domain.ValidationError{
5360
Code: domain.ErrAttachmentTooLarge,
54-
Message: fmt.Sprintf("total attachment size ~%d bytes exceeds limit %d MB", totalBytes, maxAttachMB),
61+
Message: fmt.Sprintf("total attachment size %d bytes exceeds limit %d MB", totalBytes, maxAttachMB),
5562
}
5663
}
5764
}
@@ -87,23 +94,3 @@ func checkDomains(sender domain.Sender, req *domain.MailRequest) error {
8794
}
8895
return nil
8996
}
90-
91-
// decodeAttachments converts base64 content strings to raw bytes.
92-
func decodeAttachments(atts []domain.Attachment) ([]domain.AttachmentDO, error) {
93-
result := make([]domain.AttachmentDO, 0, len(atts))
94-
for _, a := range atts {
95-
raw, err := base64.StdEncoding.DecodeString(a.Content)
96-
if err != nil {
97-
return nil, &domain.ValidationError{
98-
Code: domain.ErrInvalidAttachmentType,
99-
Message: fmt.Sprintf("attachment %q: invalid base64", a.Name),
100-
}
101-
}
102-
result = append(result, domain.AttachmentDO{
103-
Name: a.Name,
104-
ContentType: a.MimeType,
105-
Content: raw,
106-
})
107-
}
108-
return result, nil
109-
}

internal/gateway/validation_test.go

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -119,44 +119,28 @@ func TestCheckDomains_CCBlocked(t *testing.T) {
119119
}
120120
}
121121

122-
func TestDecodeAttachments_Empty(t *testing.T) {
123-
result, err := decodeAttachments(nil)
124-
if err != nil || len(result) != 0 {
125-
t.Fatalf("expected empty result, nil; got %v, %v", result, err)
126-
}
127-
}
128-
129-
func TestDecodeAttachments_Valid(t *testing.T) {
130-
atts := []domain.Attachment{
131-
{Name: "f.pdf", MimeType: validationTestMime, Content: "aGVsbG8="}, // "hello"
132-
}
133-
result, err := decodeAttachments(atts)
134-
if err != nil {
135-
t.Fatalf("unexpected error: %v", err)
136-
}
137-
if len(result) != 1 || string(result[0].Content) != "hello" {
138-
t.Errorf("unexpected result: %+v", result)
139-
}
140-
}
141-
142-
func TestDecodeAttachments_InvalidBase64(t *testing.T) {
143-
atts := []domain.Attachment{
144-
{Name: "bad.pdf", MimeType: validationTestMime, Content: "not-valid-base64!!!"},
122+
func TestValidateRequest_InvalidBase64Attachment(t *testing.T) {
123+
req := &domain.MailRequest{
124+
AppTag: "t",
125+
Recipients: []string{validationTestEmail},
126+
Attachments: []domain.Attachment{
127+
{Name: "bad.pdf", MimeType: validationTestMime, Content: "not-valid-base64!!!"},
128+
},
145129
}
146-
_, err := decodeAttachments(atts)
130+
err := validateRequest(req, 10_000_000, []string{validationTestMime}, 20)
147131
var ve *domain.ValidationError
148132
if !errors.As(err, &ve) || ve.Code != domain.ErrInvalidAttachmentType {
149133
t.Fatalf("expected ErrInvalidAttachmentType for bad base64, got %v", err)
150134
}
151135
}
152136

153137
func TestValidateRequest_AttachmentTotalSizeExceeded(t *testing.T) {
154-
// 2 MB content string → ~1.5 MB raw, exceeds 1 MB limit
138+
// 1.6 MB of valid base64 ("AAAA"×400000) decodes to 1.2 MB exceeds 1 MB limit
155139
req := &domain.MailRequest{
156140
AppTag: "t",
157141
Recipients: []string{validationTestEmail},
158142
Attachments: []domain.Attachment{
159-
{Name: "big.pdf", MimeType: validationTestMime, Content: string(make([]byte, 2*1024*1024))},
143+
{Name: "big.pdf", MimeType: validationTestMime, Content: strings.Repeat("AAAA", 400_000)},
160144
},
161145
}
162146
err := validateRequest(req, 10_000_000, []string{validationTestMime}, 1)

0 commit comments

Comments
 (0)