Merge branch 'sipeed:main' into main
This commit is contained in:
commit
a1c905876d
4 changed files with 165 additions and 8 deletions
|
|
@ -32,6 +32,10 @@ const (
|
||||||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
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 {
|
type replyTokenEntry struct {
|
||||||
|
|
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
return
|
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")
|
signature := r.Header.Get("X-Line-Signature")
|
||||||
if !c.verifySignature(body, signature) {
|
if !c.verifySignature(body, signature) {
|
||||||
|
|
|
||||||
81
pkg/channels/line/line_test.go
Normal file
81
pkg/channels/line/line_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -726,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
resp, err := c.client.Download(reqCtx, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := resp.Body
|
||||||
|
readerClose := func() error { return nil }
|
||||||
|
|
||||||
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||||
err = msgEvt.File.DecryptInPlace(data)
|
if err = msgEvt.File.PrepareForDecryption(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
}
|
}
|
||||||
|
decryptReader := msgEvt.File.DecryptStream(resp.Body)
|
||||||
|
reader = decryptReader
|
||||||
|
readerClose = decryptReader.Close
|
||||||
}
|
}
|
||||||
|
|
||||||
label := matrixMediaLabel(msgEvt, mediaKind)
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
|
@ -749,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer tmp.Close()
|
tmpPath := tmp.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
if cleanup {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if _, err = tmp.Write(data); err != nil {
|
_, err = io.Copy(tmp, reader)
|
||||||
_ = os.Remove(tmp.Name())
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = readerClose(); err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
|
}
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmp.Name(), nil
|
cleanup = false
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package matrix
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -197,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
|
||||||
|
const wantBody = "matrix-media-payload"
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
|
||||||
|
t.Fatalf("unexpected download path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte(wantBody))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &MatrixChannel{client: client}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "image.png",
|
||||||
|
URL: id.ContentURIString("mxc://matrix.test/abc123"),
|
||||||
|
Info: &event.FileInfo{MimeType: "image/png"},
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := ch.downloadMedia(context.Background(), msg, "image")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadMedia: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if ext := filepath.Ext(path); ext != ".png" {
|
||||||
|
t.Fatalf("temp file extension=%q want=.png", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != wantBody {
|
||||||
|
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
||||||
ch := &MatrixChannel{}
|
ch := &MatrixChannel{}
|
||||||
msg := &event.MessageEventContent{
|
msg := &event.MessageEventContent{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue