Skip to content

Commit 6c8a2e0

Browse files
committed
fix(egress): address P1 MITM bypass and P2 validation gaps
- Add SO_MARK transport to HTTP source fetches so outbound connections bypass the transparent iptables redirect (prevents MITM → active snapshot → singleflight deadlock on first fetch / TTL expiry) - Add mark-based RETURN rule to transparent HTTP iptables chain - Validate URL host presence, HTTP method, and header names/values at write time instead of failing silently at activation - Remove default keyword from OpenAPI spec method field so generated TS client treats it as optional - Regenerate JS and Python API clients
1 parent 0eda00c commit 6c8a2e0

8 files changed

Lines changed: 130 additions & 10 deletions

File tree

components/egress/pkg/credentialvault/source_http.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"errors"
2222
"fmt"
2323
"io"
24+
"net"
2425
"net/http"
2526
"net/url"
2627
"sync"
@@ -31,6 +32,10 @@ import (
3132

3233
const httpSourceDefaultTimeout = 10 * time.Second
3334

35+
func defaultTransportDialer() *net.Dialer {
36+
return &net.Dialer{Timeout: 5 * time.Second}
37+
}
38+
3439
type httpSource struct {
3540
mu sync.RWMutex
3641
sf singleflight.Group
@@ -172,15 +177,32 @@ func httpSourceFactory(raw json.RawMessage) (CredentialSource, error) {
172177
if parsed.Scheme != "https" && parsed.Scheme != "http" {
173178
return nil, fmt.Errorf("http credential source url must use http or https scheme, got %q", parsed.Scheme)
174179
}
180+
if parsed.Host == "" {
181+
return nil, fmt.Errorf("http credential source url must include a host")
182+
}
175183
if cfg.Method == "" {
176184
cfg.Method = http.MethodGet
177185
}
186+
if _, err := http.NewRequest(cfg.Method, cfg.URL, nil); err != nil {
187+
return nil, fmt.Errorf("http credential source: invalid method or url: %w", err)
188+
}
189+
for name, value := range cfg.Headers {
190+
if !headerFieldNamePattern.MatchString(name) {
191+
return nil, fmt.Errorf("http credential source: invalid header name %q", name)
192+
}
193+
for i := range value {
194+
if value[i] == '\r' || value[i] == '\n' {
195+
return nil, fmt.Errorf("http credential source: header %q value contains CR/LF", name)
196+
}
197+
}
198+
}
178199
return &httpSource{
179200
initialURL: cfg.URL,
180201
initialMethod: cfg.Method,
181202
initialHeaders: cfg.Headers,
182203
client: &http.Client{
183-
Timeout: httpSourceDefaultTimeout,
204+
Timeout: httpSourceDefaultTimeout,
205+
Transport: httpSourceTransport(),
184206
CheckRedirect: func(req *http.Request, via []*http.Request) error {
185207
return errors.New("http credential source: redirects are not allowed")
186208
},

components/egress/pkg/credentialvault/source_http_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,43 @@ func TestHttpSourceFactoryRejectsRelativeURL(t *testing.T) {
275275
require.ErrorContains(t, err, "must use http or https scheme")
276276
}
277277

278+
func TestHttpSourceFactoryRejectsHostlessURL(t *testing.T) {
279+
for _, u := range []string{"http:/token", "https:///token", "http://"} {
280+
_, err := httpSourceFactory(mustMarshal(map[string]string{
281+
"type": "http",
282+
"url": u,
283+
}))
284+
require.ErrorContains(t, err, "must include a host", u)
285+
}
286+
}
287+
288+
func TestHttpSourceFactoryRejectsInvalidMethod(t *testing.T) {
289+
_, err := httpSourceFactory(mustMarshal(map[string]any{
290+
"type": "http",
291+
"url": "https://vault.example.com/cred",
292+
"method": "BAD METHOD",
293+
}))
294+
require.ErrorContains(t, err, "invalid method")
295+
}
296+
297+
func TestHttpSourceFactoryRejectsInvalidHeaderName(t *testing.T) {
298+
_, err := httpSourceFactory(mustMarshal(map[string]any{
299+
"type": "http",
300+
"url": "https://vault.example.com/cred",
301+
"headers": map[string]string{"Bad Header": "value"},
302+
}))
303+
require.ErrorContains(t, err, "invalid header name")
304+
}
305+
306+
func TestHttpSourceFactoryRejectsHeaderValueWithCRLF(t *testing.T) {
307+
_, err := httpSourceFactory(mustMarshal(map[string]any{
308+
"type": "http",
309+
"url": "https://vault.example.com/cred",
310+
"headers": map[string]string{"X-Auth": "value\r\ninjection"},
311+
}))
312+
require.ErrorContains(t, err, "CR/LF")
313+
}
314+
278315
func TestHttpSourceRejectsRedirects(t *testing.T) {
279316
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
280317
http.Redirect(w, r, "https://evil.example.com", http.StatusFound)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build linux
16+
17+
package credentialvault
18+
19+
import (
20+
"net/http"
21+
"syscall"
22+
23+
"golang.org/x/sys/unix"
24+
25+
"github.com/alibaba/opensandbox/egress/pkg/constants"
26+
)
27+
28+
func httpSourceTransport() http.RoundTripper {
29+
dialer := defaultTransportDialer()
30+
dialer.Control = func(network, address string, c syscall.RawConn) error {
31+
var opErr error
32+
if err := c.Control(func(fd uintptr) {
33+
opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue)
34+
}); err != nil {
35+
return err
36+
}
37+
return opErr
38+
}
39+
return &http.Transport{DialContext: dialer.DialContext}
40+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build !linux
16+
17+
package credentialvault
18+
19+
import "net/http"
20+
21+
func httpSourceTransport() http.RoundTripper {
22+
return &http.Transport{DialContext: defaultTransportDialer().DialContext}
23+
}

components/egress/pkg/iptables/transparent.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"strconv"
2222
"strings"
2323

24+
"github.com/alibaba/opensandbox/egress/pkg/constants"
2425
"github.com/alibaba/opensandbox/egress/pkg/log"
2526
)
2627

@@ -29,6 +30,7 @@ func transparentHTTPRules(localPort int, mitmUID uint32, op string) [][]string {
2930
uid := strconv.FormatUint(uint64(mitmUID), 10)
3031
loopRules := [][]string{
3132
{"iptables", "-t", "nat", op, "OUTPUT", "-p", "tcp", "-d", "127.0.0.0/8", "-j", "RETURN"},
33+
{"iptables", "-t", "nat", op, "OUTPUT", "-p", "tcp", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"},
3234
}
3335
redir := [][]string{
3436
{

sdks/sandbox/javascript/src/api/egress.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -521,11 +521,8 @@ export interface components {
521521
type: "http";
522522
/** @description HTTP endpoint URL to fetch the credential from. */
523523
url: string;
524-
/**
525-
* @description HTTP method. Defaults to GET.
526-
* @default GET
527-
*/
528-
method: string;
524+
/** @description HTTP method. Defaults to GET when omitted. */
525+
method?: string;
529526
/** @description Optional static headers sent with the request. */
530527
headers?: {
531528
[key: string]: string;

sdks/sandbox/python/src/opensandbox/api/egress/models/http_credential_source.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ class HTTPCredentialSource:
3737
Attributes:
3838
type_ (HTTPCredentialSourceType):
3939
url (str): HTTP endpoint URL to fetch the credential from.
40-
method (str | Unset): HTTP method. Defaults to GET. Default: 'GET'.
40+
method (str | Unset): HTTP method. Defaults to GET when omitted.
4141
headers (HTTPCredentialSourceHeaders | Unset): Optional static headers sent with the request.
4242
"""
4343

4444
type_: HTTPCredentialSourceType
4545
url: str
46-
method: str | Unset = "GET"
46+
method: str | Unset = UNSET
4747
headers: HTTPCredentialSourceHeaders | Unset = UNSET
4848

4949
def to_dict(self) -> dict[str, Any]:

specs/egress-api.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,7 @@ components:
554554
description: HTTP endpoint URL to fetch the credential from.
555555
method:
556556
type: string
557-
default: GET
558-
description: HTTP method. Defaults to GET.
557+
description: HTTP method. Defaults to GET when omitted.
559558
headers:
560559
type: object
561560
additionalProperties:

0 commit comments

Comments
 (0)