Skip to content

Commit f215ddf

Browse files
committed
fix(egress): use dedicated fwmark and harden response validation
- Use separate CredentialFetchMark (0x2) for HTTP source transport so it bypasses iptables transparent redirect but NOT nft egress policy - Validate rotated URLs from provider responses before caching - Clear bootstrap headers when provider rotates URL without headers - Reject all control characters in header values, not just CR/LF
1 parent e3d20e6 commit f215ddf

5 files changed

Lines changed: 85 additions & 22 deletions

File tree

components/egress/pkg/constants/constants.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ package constants
1717
const (
1818
MarkValue = 0x1
1919
MarkHex = "0x1"
20+
21+
// CredentialFetchMarkValue bypasses iptables transparent HTTP redirect
22+
// without bypassing nft egress policy (unlike MarkValue which does both).
23+
CredentialFetchMarkValue = 0x2
24+
CredentialFetchMarkHex = "0x2"
2025
)
2126

2227
const (

components/egress/pkg/credentialvault/source_http.go

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,13 @@ func (s *httpSource) fetch(ctx context.Context) (string, error) {
144144
s.expiresAt = time.Time{}
145145
}
146146
if result.URL != "" {
147-
s.nextURL = result.URL
147+
if validateHTTPSourceURL(result.URL) == nil {
148+
s.nextURL = result.URL
149+
if result.Headers == nil {
150+
s.nextHeaders = nil
151+
s.headersRotated = true
152+
}
153+
}
148154
}
149155
if result.Headers != nil {
150156
s.nextHeaders = result.Headers
@@ -170,31 +176,17 @@ func httpSourceFactory(raw json.RawMessage) (CredentialSource, error) {
170176
if cfg.URL == "" {
171177
return nil, fmt.Errorf("http credential source url cannot be empty")
172178
}
173-
parsed, err := url.Parse(cfg.URL)
174-
if err != nil {
175-
return nil, fmt.Errorf("http credential source url: %w", err)
176-
}
177-
if parsed.Scheme != "https" && parsed.Scheme != "http" {
178-
return nil, fmt.Errorf("http credential source url must use http or https scheme, got %q", parsed.Scheme)
179-
}
180-
if parsed.Host == "" {
181-
return nil, fmt.Errorf("http credential source url must include a host")
179+
if err := validateHTTPSourceURL(cfg.URL); err != nil {
180+
return nil, err
182181
}
183182
if cfg.Method == "" {
184183
cfg.Method = http.MethodGet
185184
}
186185
if _, err := http.NewRequest(cfg.Method, cfg.URL, nil); err != nil {
187186
return nil, fmt.Errorf("http credential source: invalid method or url: %w", err)
188187
}
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-
}
188+
if err := validateHTTPSourceHeaders(cfg.Headers); err != nil {
189+
return nil, err
198190
}
199191
return &httpSource{
200192
initialURL: cfg.URL,
@@ -209,3 +201,32 @@ func httpSourceFactory(raw json.RawMessage) (CredentialSource, error) {
209201
},
210202
}, nil
211203
}
204+
205+
func validateHTTPSourceHeaders(headers map[string]string) error {
206+
for name, value := range headers {
207+
if !headerFieldNamePattern.MatchString(name) {
208+
return fmt.Errorf("http credential source: invalid header name %q", name)
209+
}
210+
for i := range value {
211+
b := value[i]
212+
if b < 0x20 || b == 0x7f {
213+
return fmt.Errorf("http credential source: header %q value contains invalid character 0x%02x", name, b)
214+
}
215+
}
216+
}
217+
return nil
218+
}
219+
220+
func validateHTTPSourceURL(raw string) error {
221+
parsed, err := url.Parse(raw)
222+
if err != nil {
223+
return fmt.Errorf("http credential source url: %w", err)
224+
}
225+
if parsed.Scheme != "https" && parsed.Scheme != "http" {
226+
return fmt.Errorf("http credential source url must use http or https scheme, got %q", parsed.Scheme)
227+
}
228+
if parsed.Host == "" {
229+
return fmt.Errorf("http credential source url must include a host")
230+
}
231+
return nil
232+
}

components/egress/pkg/credentialvault/source_http_test.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,44 @@ func TestHttpSourceFactoryRejectsHeaderValueWithCRLF(t *testing.T) {
309309
"url": "https://vault.example.com/cred",
310310
"headers": map[string]string{"X-Auth": "value\r\ninjection"},
311311
}))
312-
require.ErrorContains(t, err, "CR/LF")
312+
require.ErrorContains(t, err, "invalid character")
313+
}
314+
315+
func TestHttpSourceFactoryRejectsHeaderValueWithControlChar(t *testing.T) {
316+
_, err := httpSourceFactory(mustMarshal(map[string]any{
317+
"type": "http",
318+
"url": "https://vault.example.com/cred",
319+
"headers": map[string]string{"X-Auth": "value\x00null"},
320+
}))
321+
require.ErrorContains(t, err, "invalid character")
322+
}
323+
324+
func TestHttpSourceURLOnlyRotationClearsBootstrapHeaders(t *testing.T) {
325+
var calls atomic.Int32
326+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
327+
n := calls.Add(1)
328+
if n == 1 {
329+
require.Equal(t, "boot", r.Header.Get("X-Auth"))
330+
fmt.Fprintf(w, `{"value":"first","url":"http://%s/refreshed","ttl":0}`, r.Host)
331+
return
332+
}
333+
require.Equal(t, "", r.Header.Get("X-Auth"), "bootstrap header should not leak to rotated URL")
334+
fmt.Fprintf(w, `{"value":"second","ttl":0}`)
335+
}))
336+
defer srv.Close()
337+
338+
src, err := httpSourceFactory(mustMarshal(map[string]any{
339+
"type": "http",
340+
"url": srv.URL,
341+
"headers": map[string]string{"X-Auth": "boot"},
342+
}))
343+
require.NoError(t, err)
344+
345+
_, err = src.Resolve(context.Background())
346+
require.NoError(t, err)
347+
348+
_, err = src.Resolve(context.Background())
349+
require.NoError(t, err)
313350
}
314351

315352
func TestHttpSourceRejectsRedirects(t *testing.T) {

components/egress/pkg/credentialvault/source_http_transport_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func httpSourceTransport() http.RoundTripper {
3232
// Best-effort: requires CAP_NET_ADMIN. In environments without
3333
// iptables transparent redirect (tests, dev), the mark is
3434
// unnecessary and the error is harmless.
35-
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue)
35+
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.CredentialFetchMarkValue)
3636
})
3737
return nil
3838
}

components/egress/pkg/iptables/transparent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func transparentHTTPRules(localPort int, mitmUID uint32, op string) [][]string {
3030
uid := strconv.FormatUint(uint64(mitmUID), 10)
3131
loopRules := [][]string{
3232
{"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"},
33+
{"iptables", "-t", "nat", op, "OUTPUT", "-p", "tcp", "-m", "mark", "--mark", constants.CredentialFetchMarkHex, "-j", "RETURN"},
3434
}
3535
redir := [][]string{
3636
{

0 commit comments

Comments
 (0)