diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index b36350a06..56ba02183 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -32,6 +32,10 @@ const ( lineBotInfoEndpoint = lineAPIBase + "/info" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineReplyTokenMaxAge = 25 * time.Second + + // Limit request body to prevent memory exhaustion (DoS). + // LINE webhook payloads are typically a few KB; 1 MiB is generous. + maxWebhookBodySize = 1 << 20 // 1 MiB ) type replyTokenEntry struct { @@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) if err != nil { logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), @@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad request", http.StatusBadRequest) return } + if int64(len(body)) > maxWebhookBodySize { + logger.WarnC("line", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + return + } signature := r.Header.Get("X-Line-Signature") if !c.verifySignature(body, signature) { diff --git a/pkg/channels/line/line_test.go b/pkg/channels/line/line_test.go new file mode 100644 index 000000000..00770f1c7 --- /dev/null +++ b/pkg/channels/line/line_test.go @@ -0,0 +1,81 @@ +package line + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWebhookRejectsOversizedBody(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookAcceptsMaxBodySize(t *testing.T) { + ch := &LINEChannel{} + + body := bytes.Repeat([]byte("A"), maxWebhookBodySize) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + // Missing signature should be rejected, but the body size should not trigger 413. + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookRejectsNonPostMethod(t *testing.T) { + ch := &LINEChannel{} + + req := httptest.NewRequest(http.MethodGet, "/webhook", nil) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestWebhookRejectsInvalidSignature(t *testing.T) { + ch := &LINEChannel{} + + body := `{"events":[]}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +}