Skip to content

Commit 99718d9

Browse files
committed
🛡️ all: limit http response body size
1 parent b1f9e88 commit 99718d9

5 files changed

Lines changed: 129 additions & 13 deletions

File tree

internal/httpreq/httpreq.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,21 @@ func NewFormRequest(ctx context.Context, method, url string, values url.Values)
5757
req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"}
5858
return req, nil
5959
}
60+
61+
// ReadResponseBody reads up to maxSize bytes from the response body into buf.
62+
// If the size of the response body exceeds maxSize, an error is returned.
63+
func ReadResponseBody(buf *bytes.Buffer, resp *http.Response, maxSize int64) error {
64+
if resp.ContentLength > maxSize {
65+
return fmt.Errorf("response body too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)
66+
}
67+
buf.Grow(max(0, int(resp.ContentLength)))
68+
r := io.LimitReader(resp.Body, maxSize+1)
69+
n, err := io.Copy(buf, r)
70+
if err != nil {
71+
return err
72+
}
73+
if n > maxSize {
74+
return fmt.Errorf("response body too large: %d bytes (max %d bytes)", n, maxSize)
75+
}
76+
return nil
77+
}

internal/httpreq/httpreq_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package httpreq_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/database64128/ddns-go/internal/httpreq"
11+
)
12+
13+
func TestReadResponseBody(t *testing.T) {
14+
for _, c := range [...]struct {
15+
name string
16+
resp http.Response
17+
maxSize int64
18+
wantLen int
19+
wantErr bool
20+
}{
21+
{
22+
name: "SuccessExactContentLength",
23+
resp: http.Response{
24+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
25+
ContentLength: 13,
26+
},
27+
maxSize: 20,
28+
wantLen: 13,
29+
wantErr: false,
30+
},
31+
{
32+
name: "SuccessShortContentLength",
33+
resp: http.Response{
34+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
35+
ContentLength: 5,
36+
},
37+
maxSize: 20,
38+
wantLen: 13,
39+
wantErr: false,
40+
},
41+
{
42+
name: "SuccessNoContentLength",
43+
resp: http.Response{
44+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
45+
ContentLength: -1,
46+
},
47+
maxSize: 20,
48+
wantLen: 13,
49+
wantErr: false,
50+
},
51+
{
52+
name: "SuccessExactMaxSize",
53+
resp: http.Response{
54+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
55+
ContentLength: 13,
56+
},
57+
maxSize: 13,
58+
wantLen: 13,
59+
wantErr: false,
60+
},
61+
{
62+
name: "ErrorContentLengthExceedsMaxSize",
63+
resp: http.Response{
64+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
65+
ContentLength: 30,
66+
},
67+
maxSize: 20,
68+
wantLen: 0,
69+
wantErr: true,
70+
},
71+
{
72+
name: "ErrorReadExceedsMaxSize",
73+
resp: http.Response{
74+
Body: io.NopCloser(strings.NewReader("Hello, World!")),
75+
ContentLength: -1,
76+
},
77+
maxSize: 5,
78+
wantLen: 6,
79+
wantErr: true,
80+
},
81+
} {
82+
t.Run(c.name, func(t *testing.T) {
83+
var buf bytes.Buffer
84+
err := httpreq.ReadResponseBody(&buf, &c.resp, c.maxSize)
85+
if (err != nil) != c.wantErr {
86+
t.Errorf("ReadResponseBody() error = %v, wantErr %v", err, c.wantErr)
87+
}
88+
if got := buf.Len(); got != c.wantLen {
89+
t.Errorf("ReadResponseBody() read %d bytes, want %d", got, c.wantLen)
90+
}
91+
})
92+
}
93+
}

producer/asusrouter/asusrouter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/xml"
99
"errors"
1010
"fmt"
11+
"io"
1112
"net/http"
1213
"net/netip"
1314
"net/url"
@@ -136,6 +137,9 @@ func (s *Source) Snapshot(ctx context.Context) (producer.Message, error) {
136137
return producer.Message{}, fmt.Errorf("AJAX status failed with status %d", resp.StatusCode)
137138
}
138139

140+
const maxResponseBodySize = 1024 * 1024 // 1 MiB
141+
r := io.LimitReader(resp.Body, maxResponseBodySize)
142+
139143
// Parse the XML response and extract the IP address.
140144
// The response is expected to be in the following format:
141145
//
@@ -151,7 +155,7 @@ func (s *Source) Snapshot(ctx context.Context) (producer.Message, error) {
151155
}
152156

153157
var dm deviceMap
154-
if err := xml.NewDecoder(resp.Body).Decode(&dm); err != nil {
158+
if err := xml.NewDecoder(r).Decode(&dm); err != nil {
155159
return producer.Message{}, fmt.Errorf("failed to decode AJAX status XML: %w", err)
156160
}
157161

producer/ipapi/text.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7-
"io"
87
"net"
98
"net/http"
109
"net/netip"
@@ -129,19 +128,22 @@ func (s *textSource) get(ctx context.Context) (netip.Addr, error) {
129128
if err != nil {
130129
return netip.Addr{}, fmt.Errorf("failed to send request: %w", err)
131130
}
131+
defer resp.Body.Close()
132132

133-
body, err := io.ReadAll(resp.Body)
134-
resp.Body.Close()
135-
if resp.StatusCode != http.StatusOK {
136-
return netip.Addr{}, fmt.Errorf("unexpected status code %d: %q", resp.StatusCode, body)
137-
}
138-
if err != nil {
133+
const maxResponseBodySize = int64(len("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff") + 25) // 64
134+
var buf bytes.Buffer
135+
if err = httpreq.ReadResponseBody(&buf, resp, maxResponseBodySize); err != nil {
139136
return netip.Addr{}, fmt.Errorf("failed to read response body: %w", err)
140137
}
138+
bodyBytes := buf.Bytes()
139+
140+
if resp.StatusCode != http.StatusOK {
141+
return netip.Addr{}, fmt.Errorf("unexpected status code %d: %q", resp.StatusCode, bodyBytes)
142+
}
141143

142-
body = bytes.TrimSpace(body)
144+
bodyBytes = bytes.TrimSpace(bodyBytes)
143145

144-
addr, err := netip.ParseAddr(string(body))
146+
addr, err := netip.ParseAddr(string(bodyBytes))
145147
if err != nil {
146148
return netip.Addr{}, fmt.Errorf("failed to parse IP address: %w", err)
147149
}

provider/cloudflare/client.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8-
"io"
98
"net/http"
109
"net/url"
1110
"strconv"
@@ -237,9 +236,9 @@ func clientDo[R any](client *http.Client, authorizationHeader string, newRequest
237236
}
238237
defer resp.Body.Close()
239238

239+
const maxResponseBodySize = 128 * 1024 * 1024 // 128 MiB
240240
var buf bytes.Buffer
241-
buf.Grow(max(0, int(resp.ContentLength)))
242-
if _, err = io.Copy(&buf, resp.Body); err != nil {
241+
if err = httpreq.ReadResponseBody(&buf, resp, maxResponseBodySize); err != nil {
243242
return result, fmt.Errorf("failed to read response: %w", err)
244243
}
245244
bodyBytes := buf.Bytes()

0 commit comments

Comments
 (0)