Skip to content

Commit 9e85914

Browse files
committed
feat: SSRF protection for HTTP clients
- Extract SSRF protection logic into reusable `httpx.GetSSRFDialContext` - Apply SSRF protection to `Global HTTP Client`, `Service HTTP Client`, and `IO HTTP Client` - Introduce `EnableAppPrivateNet` configuration to allow internal network access (default: false) Signed-off-by: Jiyong Huang <huangjy@emqx.io> # Conflicts: # internal/pkg/httpx/http.go
1 parent acc4881 commit 9e85914

File tree

16 files changed

+118
-25
lines changed

16 files changed

+118
-25
lines changed

docs/en_US/configuration/global_configurations.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,19 @@ basic:
4848
cfgStorageType: file
4949
# If it is enabled, each REST API call will print logs
5050
enableRestAuditLog: false
51+
# If it is enabled, the rule functions can access the private network.
52+
enablePrivateNet: false
5153
```
5254
55+
The configuration item **enablePrivateNet** is used to specify whether the rule functions (e.g. valid func, sinks)
56+
can access the private network (e.g. localhost, 127.0.0.1). If it is true, the private network can be accessed. Default
57+
is false for security.
58+
59+
> [!WARNING]
60+
> Since version v2.4.0, the default value of `enablePrivateNet` is `false`, which means accessing private network
61+
> addresses is blocked by default. If your rules rely on accessing local resources (e.g., local REST services, local
62+
> files), you MUST set this configuration to `true`.
63+
5364
for debug option in basic following env is valid `KUIPER__BASIC__DEBUG=true` and if used debug value will be set to true.
5465

5566
The configuration item **ignoreCase** is used to specify whether case is ignored in SQL processing. If it is true, the column name case of the input data can be different from that defined in SQL. If the column name case in SQL statements, stream definitions, and input data can be guaranteed to be exactly the same, it is recommended to set this value to "false" to obtain better performance. Before version 1.10, its default value was true to be compatible with standard SQL; after version 1.10, its default value was changed to false for better performance.

docs/zh_CN/configuration/global_configurations.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,17 @@ basic:
4444
cfgStorageType: file
4545
# If it is enabled, each REST API call will print logs
4646
enableRestAuditLog: false
47+
# If it is enabled, the rule functions can access the private network.
48+
enablePrivateNet: false
4749
```
4850
51+
配置项 **enablePrivateNet** 用于指定规则函数(例如 valid func、sinks)是否可以访问私有网络(例如 localhost、127.0.0.1)。如果为
52+
true,则可以访问私有网络。出于安全考虑,默认为 false。
53+
54+
> [!WARNING]
55+
> 自版本 v2.4.0 起,`enablePrivateNet` 的默认值为 `false`,这意味着默认情况下会阻止访问私有网络地址。如果您的规则依赖于访问本地资源(例如本地
56+
> REST 服务、本地文件),您必须将此配置设置为 `true`。
57+
4958
将basic项目下debug的值设置为true是有效的 `KUIPER__BASIC__DEBUG=true`。
5059

5160
配置项 **ignoreCase** 用于指定 SQL 处理中是否大小写无关。若为 true,则输入数据的列名大小写可以与 SQL 中的定义不同。如果 SQL 语句中,流定义以及输入数据中可以保证列名大小写完全一致,则建议设置该值为 false 以获得更优的性能。在 1.10 版本之前,其默认值为 true , 以兼容标准 SQL ;在 1.10 及之后版本中,默认值改为 false ,以获得更优的性能。

etc/kuiper.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ basic:
7777
retainedDuration: 6h
7878
# If it is enabled, each REST API call will print logs
7979
enableRestAuditLog: false
80+
# If it is enabled, the rule functions can access the private network.
81+
enablePrivateNet: false
8082

8183
# The default options for all rules. Each rule can override this setting by defining its own option
8284
rule:

internal/io/http/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ func (cc *ClientConf) InitConf(ctx api.StreamContext, device string, props map[s
204204
return err
205205
}
206206
tr := newTransport(tlscfg, conf.Log)
207+
tr.DialContext = httpx.GetSSRFDialContext(time.Duration(c.Timeout))
207208
cc.client = &http.Client{
208209
Transport: tr,
209210
Timeout: time.Duration(c.Timeout),

internal/io/http/client_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,16 @@ import (
2121

2222
"github.com/stretchr/testify/require"
2323

24+
"github.com/lf-edge/ekuiper/v2/internal/conf"
25+
"github.com/lf-edge/ekuiper/v2/internal/testx"
2426
mockContext "github.com/lf-edge/ekuiper/v2/pkg/mock/context"
2527
)
2628

29+
func init() {
30+
testx.InitEnv("http")
31+
conf.Config.Basic.EnablePrivateNet = true
32+
}
33+
2734
func TestInitConf(t *testing.T) {
2835
m := map[string]interface{}{}
2936
ctx := mockContext.NewMockContext("1", "2")

internal/pkg/httpx/http.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@ package httpx
1616

1717
import (
1818
"bytes"
19+
"context"
1920
"crypto/tls"
2021
"fmt"
2122
"io"
2223
"mime/multipart"
24+
"net"
2325
"net/http"
2426
"net/url"
2527
"os"
2628
"strconv"
2729
"strings"
30+
"syscall"
2831
"time"
2932

3033
"github.com/lf-edge/ekuiper/contract/v2/api"
@@ -179,6 +182,7 @@ func ReadFile(uri string) (io.ReadCloser, error) {
179182
Timeout: timeout,
180183
Transport: &http.Transport{
181184
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
185+
DialContext: GetSSRFDialContext(timeout),
182186
},
183187
}
184188
resp, err := client.Get(uri)
@@ -195,6 +199,29 @@ func ReadFile(uri string) (io.ReadCloser, error) {
195199
return src, nil
196200
}
197201

202+
func GetSSRFDialContext(timeout time.Duration) func(ctx context.Context, network, addr string) (net.Conn, error) {
203+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
204+
d := net.Dialer{
205+
Timeout: timeout,
206+
Control: func(network, address string, c syscall.RawConn) error {
207+
if conf.Config != nil && conf.Config.Basic.EnablePrivateNet {
208+
return nil
209+
}
210+
host, _, err := net.SplitHostPort(address)
211+
if err != nil {
212+
return err
213+
}
214+
ip := net.ParseIP(host)
215+
if ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
216+
return fmt.Errorf("ip %s is in internal network", ip.String())
217+
}
218+
return nil
219+
},
220+
}
221+
return d.DialContext(ctx, network, addr)
222+
}
223+
}
224+
198225
func DownloadFile(folder string, name string, uri string) (err error) {
199226
src, err := ReadFile(uri)
200227
if err != nil {

internal/pkg/httpx/ssrf_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package httpx
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
11+
"github.com/lf-edge/ekuiper/v2/internal/conf"
12+
"github.com/lf-edge/ekuiper/v2/pkg/model"
13+
)
14+
15+
func TestReadFileSSRF(t *testing.T) {
16+
// Start a local test server
17+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
fmt.Fprintln(w, "secret data")
19+
}))
20+
defer server.Close()
21+
22+
// 1. Defaut behavior: should BLOCK private access
23+
// Current behavior: ReadFile checks internal/conf.Config.Basic.EnablePrivateNet
24+
// Since conf.Config is nil or default, it blocks (we should ensure it blocks if nil too, or we mock it)
25+
26+
// Ensure config is nil or clean to start
27+
conf.Config = nil
28+
29+
_, err := ReadFile(server.URL)
30+
assert.Error(t, err, "ReadFile should block access to local server by default")
31+
if err != nil {
32+
assert.Contains(t, err.Error(), "internal network")
33+
}
34+
35+
// 2. Enable private access: should ALLOW
36+
conf.Config = &model.KuiperConf{}
37+
conf.Config.Basic.EnablePrivateNet = true
38+
39+
// Reset config after test
40+
defer func() { conf.Config = nil }()
41+
42+
rc, err := ReadFile(server.URL)
43+
assert.NoError(t, err, "ReadFile should allow access when EnablePrivateNet is true")
44+
if rc != nil {
45+
rc.Close()
46+
}
47+
}

internal/plugin/native/manager_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ import (
3131

3232
"github.com/lf-edge/ekuiper/v2/internal/binder"
3333
"github.com/lf-edge/ekuiper/v2/internal/binder/function"
34+
"github.com/lf-edge/ekuiper/v2/internal/conf"
3435
"github.com/lf-edge/ekuiper/v2/internal/meta"
3536
"github.com/lf-edge/ekuiper/v2/internal/plugin"
3637
"github.com/lf-edge/ekuiper/v2/internal/testx"
3738
)
3839

3940
func init() {
4041
testx.InitEnv("native")
42+
conf.Config.Basic.EnablePrivateNet = true
4143
meta.InitYamlConfigManager()
4244
var (
4345
nativeManager *Manager

internal/plugin/native/security_test.go

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,12 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7-
"time"
87

98
"github.com/stretchr/testify/assert"
109

11-
"github.com/lf-edge/ekuiper/v2/internal/binder"
12-
"github.com/lf-edge/ekuiper/v2/internal/binder/function"
13-
"github.com/lf-edge/ekuiper/v2/internal/meta"
1410
"github.com/lf-edge/ekuiper/v2/internal/plugin"
15-
"github.com/lf-edge/ekuiper/v2/internal/testx"
1611
)
1712

18-
func init() {
19-
testx.InitEnv("native")
20-
meta.InitYamlConfigManager()
21-
var (
22-
nativeManager *Manager
23-
err error
24-
)
25-
for i := 0; i < 10; i++ {
26-
if nativeManager, err = InitManager(); err != nil {
27-
time.Sleep(10 * time.Millisecond)
28-
} else {
29-
break
30-
}
31-
}
32-
err = function.Initialize([]binder.FactoryEntry{{Name: "native plugin", Factory: nativeManager}})
33-
if err != nil {
34-
panic(err)
35-
}
36-
}
37-
3813
func TestManager_Register_PathTraversal(t *testing.T) {
3914
s := httptest.NewServer(
4015
http.FileServer(http.Dir("../testzips")),

internal/plugin/portable/manager_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/stretchr/testify/assert"
2929
"github.com/stretchr/testify/require"
3030

31+
"github.com/lf-edge/ekuiper/v2/internal/conf"
3132
"github.com/lf-edge/ekuiper/v2/internal/meta"
3233
"github.com/lf-edge/ekuiper/v2/internal/plugin"
3334
"github.com/lf-edge/ekuiper/v2/internal/testx"
@@ -37,6 +38,7 @@ import (
3738

3839
func init() {
3940
testx.InitEnv("portable")
41+
conf.Config.Basic.EnablePrivateNet = true
4042
// Wait for other db tests to finish to avoid db lock
4143
for i := 0; i < 10; i++ {
4244
if _, err := InitManager(); err != nil {

0 commit comments

Comments
 (0)