Skip to content

Commit bf258dd

Browse files
authored
fix(server): force USE_SSL=false on the S3 secret when cache proxy is set (#526)
The previous "force plaintext HTTP" mechanism — three SET GLOBAL statements at session level — was based on a misread of duckdb-httpfs behaviour. The code comment claimed DuckDB ignored secret settings and tunnelled HTTPS for AWS endpoints; what's actually happening is the inverse: // duckdb-httpfs/src/include/s3fs.hpp:30-38 template <class TYPE> SettingLookupResult TryGetSecretKeyOrSetting(...) { Value temp_result; auto setting_scope = reader.TryGetSecretKeyOrSetting(...); if (!temp_result.IsNull() && !(setting_scope.GetScope() == SettingScope::GLOBAL && !use_env_variables_for_secret_settings)) { result = temp_result.GetValue<TYPE>(); } return setting_scope; } DuckDB explicitly *drops* GLOBAL-scope settings when reading the s3 secret's use_ssl / url_style, unless the env-var-for-secret-settings flag is enabled. So `SET GLOBAL s3_use_ssl = false` was a no-op on the secret-backed S3 path. Reads happened to render as HTTP because DuckLake's parquet read path consults the *raw* http_proxy setting and session-level URL form — different code, doesn't read the secret. Writes go through the full s3fs path which honours the secret, gets use_ssl=true, and tunnels via HTTPS CONNECT — invisible to the cache proxy except as opaque byte counts. Operationally that meant every parquet write produced zero forward-proxy log lines and an upstream-rejected write surfaced only as a generic DuckDB-side "HTTP code 501" with no proxy-side breadcrumb to correlate against. Fix: embed USE_SSL=false / URL_STYLE='path' on the secret itself when HTTPProxy is set, in all three secret builders (config, credential chain, AWS SDK). resolveS3SecretTransport centralises the decision so the three sites stay in lock-step. Drop the two SET GLOBAL no-ops and rewrite the comment to reflect the actual mechanism. http_proxy stays because that *is* a session-level setting DuckDB honours. Side effect: with the secret forcing path-style, the credential-chain branch now also emits URL_STYLE/USE_SSL when no explicit endpoint is configured (previously it skipped the clause when both endpoint was empty AND no proxy was set). The proxy-set case is the only one that materially changes shape; the no-proxy / no-endpoint case is still left untouched (resolveS3SecretTransport falls through to the org's preferred values). Tests: - TestResolveS3SecretTransport: 5 cases covering proxy-overrides-vhost, proxy-with-defaults, no-proxy defaults, vhost+ssl honoured, explicit-path preserved. - TestBuildConfigSecretEmitsHTTPWhenProxySet: asserts the SQL contains URL_STYLE 'path' / USE_SSL false with proxy set, and the org's configured vhost/true otherwise. - TestBuildCredentialChainSecretEmitsHTTPWhenProxySet: pinned the new behaviour where the credential-chain secret emits URL_STYLE/USE_SSL when proxy is set even with no endpoint, and stays minimal when neither proxy nor endpoint is set. Followup to #524 / #525: with this in place, the forwardUncached logging from those PRs now actually fires on writes, restoring full request/response visibility for parquet uploads.
1 parent 3e1f16a commit bf258dd

2 files changed

Lines changed: 187 additions & 43 deletions

File tree

server/s3_secret_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package server
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestResolveS3SecretTransport pins down the discriminator that decides
9+
// what URL_STYLE / USE_SSL we embed on the duckdb_s3 secret. The choice
10+
// is operationally load-bearing: with HTTPProxy set, every S3 byte must
11+
// flow as plain HTTP through the cache proxy's forwardUncached path so
12+
// each request gets a logged Started/Finished pair. Any value other than
13+
// path + false bumps writes back to HTTPS CONNECT, where the proxy can
14+
// only see target+byte counts (TLS terminates between the worker and S3).
15+
func TestResolveS3SecretTransport(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
cfg DuckLakeConfig
19+
wantStyle string
20+
wantUseSSL string
21+
}{
22+
{
23+
name: "HTTPProxy set forces path + false regardless of cfg",
24+
cfg: DuckLakeConfig{HTTPProxy: "http://10.0.0.1:8080", S3URLStyle: "vhost", S3UseSSL: true},
25+
wantStyle: "path",
26+
wantUseSSL: "false",
27+
},
28+
{
29+
name: "HTTPProxy set with no other overrides",
30+
cfg: DuckLakeConfig{HTTPProxy: "http://10.0.0.1:8080"},
31+
wantStyle: "path",
32+
wantUseSSL: "false",
33+
},
34+
{
35+
name: "no proxy: defaults match MinIO compatibility",
36+
cfg: DuckLakeConfig{},
37+
wantStyle: "path",
38+
wantUseSSL: "false",
39+
},
40+
{
41+
name: "no proxy: vhost+ssl honored",
42+
cfg: DuckLakeConfig{S3URLStyle: "vhost", S3UseSSL: true},
43+
wantStyle: "vhost",
44+
wantUseSSL: "true",
45+
},
46+
{
47+
name: "no proxy: explicit path style preserved",
48+
cfg: DuckLakeConfig{S3URLStyle: "path", S3UseSSL: true},
49+
wantStyle: "path",
50+
wantUseSSL: "true",
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
gotStyle, gotUseSSL := resolveS3SecretTransport(tt.cfg)
57+
if gotStyle != tt.wantStyle {
58+
t.Errorf("urlStyle = %q, want %q", gotStyle, tt.wantStyle)
59+
}
60+
if gotUseSSL != tt.wantUseSSL {
61+
t.Errorf("useSSL = %q, want %q", gotUseSSL, tt.wantUseSSL)
62+
}
63+
})
64+
}
65+
}
66+
67+
// TestBuildConfigSecretEmitsHTTPWhenProxySet asserts the SQL produced by
68+
// buildConfigSecret contains URL_STYLE 'path' / USE_SSL false when an
69+
// HTTP proxy is in front, and the org's preferred values otherwise.
70+
func TestBuildConfigSecretEmitsHTTPWhenProxySet(t *testing.T) {
71+
withProxy := buildConfigSecret(DuckLakeConfig{
72+
S3AccessKey: "AKIA",
73+
S3SecretKey: "secret",
74+
S3Region: "us-east-1",
75+
S3Endpoint: "s3.us-east-1.amazonaws.com",
76+
S3URLStyle: "vhost",
77+
S3UseSSL: true,
78+
HTTPProxy: "http://10.0.0.1:8080",
79+
})
80+
if !strings.Contains(withProxy, "URL_STYLE 'path'") {
81+
t.Errorf("expected URL_STYLE 'path' with proxy set, got:\n%s", withProxy)
82+
}
83+
if !strings.Contains(withProxy, "USE_SSL false") {
84+
t.Errorf("expected USE_SSL false with proxy set, got:\n%s", withProxy)
85+
}
86+
87+
withoutProxy := buildConfigSecret(DuckLakeConfig{
88+
S3AccessKey: "AKIA",
89+
S3SecretKey: "secret",
90+
S3Region: "us-east-1",
91+
S3Endpoint: "s3.us-east-1.amazonaws.com",
92+
S3URLStyle: "vhost",
93+
S3UseSSL: true,
94+
})
95+
if !strings.Contains(withoutProxy, "URL_STYLE 'vhost'") {
96+
t.Errorf("expected URL_STYLE 'vhost' without proxy, got:\n%s", withoutProxy)
97+
}
98+
if !strings.Contains(withoutProxy, "USE_SSL true") {
99+
t.Errorf("expected USE_SSL true without proxy, got:\n%s", withoutProxy)
100+
}
101+
}
102+
103+
// TestBuildCredentialChainSecretEmitsHTTPWhenProxySet covers the
104+
// credential-chain branch, which previously only emitted USE_SSL /
105+
// URL_STYLE when an explicit endpoint was configured. With HTTPProxy set,
106+
// we always need them on the secret regardless of whether an endpoint
107+
// was given, otherwise the secret falls back to the AWS default
108+
// (use_ssl=true) and writes go via HTTPS CONNECT.
109+
func TestBuildCredentialChainSecretEmitsHTTPWhenProxySet(t *testing.T) {
110+
// No endpoint, but proxy set — must still emit USE_SSL false / path.
111+
noEndpointWithProxy := buildCredentialChainSecret(DuckLakeConfig{
112+
HTTPProxy: "http://10.0.0.1:8080",
113+
})
114+
if !strings.Contains(noEndpointWithProxy, "USE_SSL false") {
115+
t.Errorf("expected USE_SSL false with proxy + no endpoint, got:\n%s", noEndpointWithProxy)
116+
}
117+
if !strings.Contains(noEndpointWithProxy, "URL_STYLE 'path'") {
118+
t.Errorf("expected URL_STYLE 'path' with proxy + no endpoint, got:\n%s", noEndpointWithProxy)
119+
}
120+
121+
// No endpoint, no proxy — secret stays minimal (DuckDB defaults apply).
122+
bare := buildCredentialChainSecret(DuckLakeConfig{})
123+
if strings.Contains(bare, "USE_SSL") || strings.Contains(bare, "URL_STYLE") {
124+
t.Errorf("expected no SSL/URL_STYLE clauses when neither endpoint nor proxy set, got:\n%s", bare)
125+
}
126+
}

server/server.go

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,18 +1375,20 @@ func AttachDuckLake(db *sql.DB, dlCfg DuckLakeConfig, sem chan struct{}, dataDir
13751375
// read (some settings don't propagate to DuckLake's subcatalogs post-attach,
13761376
// same gotcha as pg_pool_max_connections).
13771377
if dlCfg.HTTPProxy != "" {
1378-
// Force plaintext HTTP + path-style at the session level in addition to
1379-
// the S3 secret's USE_SSL/URL_STYLE — DuckDB observed to ignore secret
1380-
// settings and tunnel via HTTPS CONNECT when the endpoint looks like AWS
1381-
// S3, which the proxy can't cache (encrypted tunnel).
1382-
for _, stmt := range []string{
1383-
fmt.Sprintf("SET GLOBAL http_proxy = '%s'", dlCfg.HTTPProxy),
1384-
"SET GLOBAL s3_use_ssl = false",
1385-
"SET GLOBAL s3_url_style = 'path'",
1386-
} {
1387-
if _, err := db.Exec(stmt); err != nil {
1388-
slog.Warn("Failed to set httpfs proxy config.", "stmt", stmt, "error", err)
1389-
}
1378+
// Only http_proxy is set globally. Session-level SET GLOBAL
1379+
// s3_use_ssl = false / s3_url_style = 'path' used to live here too,
1380+
// as a "belt and suspenders" against DuckDB allegedly tunnelling
1381+
// HTTPS for AWS endpoints — but that read of the bug was wrong:
1382+
// duckdb-httpfs/src/include/s3fs.hpp's TryGetSecretKeyOrSetting
1383+
// explicitly *drops* GLOBAL-scope settings when reading the s3
1384+
// secret's use_ssl/url_style, so those SET GLOBALs were no-ops on
1385+
// the secret-backed S3 path. The actual fix is to embed
1386+
// USE_SSL=false / URL_STYLE='path' on the secret itself, which
1387+
// resolveS3SecretTransport now does whenever HTTPProxy is set —
1388+
// see buildConfigSecret / buildCredentialChainSecret /
1389+
// buildAWSSdkSecret.
1390+
if _, err := db.Exec(fmt.Sprintf("SET GLOBAL http_proxy = '%s'", dlCfg.HTTPProxy)); err != nil {
1391+
slog.Warn("Failed to set httpfs proxy config.", "stmt", "SET GLOBAL http_proxy", "error", err)
13901392
}
13911393
slog.Info("Routed httpfs traffic through forward HTTP proxy.", "proxy", dlCfg.HTTPProxy)
13921394
}
@@ -1777,22 +1779,45 @@ func RefreshS3Secret(db *sql.DB, dlCfg DuckLakeConfig, duckLakeSem chan struct{}
17771779
return nil
17781780
}
17791781

1782+
// resolveS3SecretTransport picks the URL_STYLE and USE_SSL values to embed in
1783+
// the DuckDB S3 secret. When the cache proxy is in front of the worker
1784+
// (HTTPProxy set), we force url_style=path + use_ssl=false so all S3
1785+
// traffic flows as plain HTTP through forwardUncached/HandleProxy on the
1786+
// proxy side — that's the only path that gives us per-request log lines
1787+
// with method, status, and body preview. Without this override, an S3
1788+
// secret with use_ssl=true makes httpfs tunnel writes via HTTPS CONNECT,
1789+
// where the proxy can only log target+byte counts (handleConnect can't
1790+
// see inside the TLS stream).
1791+
//
1792+
// Per duckdb-httpfs/src/include/s3fs.hpp:30-38, S3KeyValueReader's
1793+
// TryGetSecretKeyOrSetting drops GLOBAL-scope settings unless the
1794+
// use_env_variables_for_secret_settings flag is on. So a session-level
1795+
// SET GLOBAL s3_use_ssl = false is silently ignored on the s3fs path —
1796+
// the only effective place to set USE_SSL is on the secret itself, which
1797+
// is what this helper does.
1798+
func resolveS3SecretTransport(dlCfg DuckLakeConfig) (urlStyle string, useSSL string) {
1799+
if dlCfg.HTTPProxy != "" {
1800+
return "path", "false"
1801+
}
1802+
urlStyle = dlCfg.S3URLStyle
1803+
if urlStyle == "" {
1804+
urlStyle = "path" // default for MinIO compatibility
1805+
}
1806+
useSSL = "false"
1807+
if dlCfg.S3UseSSL {
1808+
useSSL = "true"
1809+
}
1810+
return urlStyle, useSSL
1811+
}
1812+
17801813
// buildConfigSecret builds a CREATE SECRET statement with explicit credentials
17811814
func buildConfigSecret(dlCfg DuckLakeConfig) string {
17821815
region := dlCfg.S3Region
17831816
if region == "" {
17841817
region = "us-east-1"
17851818
}
17861819

1787-
urlStyle := dlCfg.S3URLStyle
1788-
if urlStyle == "" {
1789-
urlStyle = "path" // Default to path style for MinIO compatibility
1790-
}
1791-
1792-
useSSL := "false"
1793-
if dlCfg.S3UseSSL {
1794-
useSSL = "true"
1795-
}
1820+
urlStyle, useSSL := resolveS3SecretTransport(dlCfg)
17961821

17971822
// Build base secret with explicit credentials
17981823
secret := fmt.Sprintf(`
@@ -1847,21 +1872,19 @@ func buildCredentialChainSecret(dlCfg DuckLakeConfig) string {
18471872
secret += fmt.Sprintf(",\n\t\t\tREGION '%s'", dlCfg.S3Region)
18481873
}
18491874

1850-
// Add endpoint if specified (for custom S3-compatible storage)
1851-
if dlCfg.S3Endpoint != "" {
1852-
secret += fmt.Sprintf(",\n\t\t\tENDPOINT '%s'", dlCfg.S3Endpoint)
1853-
1854-
// Also set URL style and SSL for custom endpoints
1855-
urlStyle := dlCfg.S3URLStyle
1856-
if urlStyle == "" {
1857-
urlStyle = "path"
1875+
// Set URL style and SSL on the secret itself. duckdb-httpfs only honors
1876+
// these from the secret (or env vars if the env-var-for-secret-settings
1877+
// flag is on) — `SET GLOBAL s3_use_ssl = ...` at the session level is
1878+
// dropped by S3KeyValueReader::TryGetSecretKeyOrSetting because it
1879+
// filters GLOBAL-scope settings unless the env-var path is enabled.
1880+
// Setting it on the secret is the only knob that actually controls the
1881+
// http_proto = use_ssl ? "https://" : "http://" decision in s3fs.cpp.
1882+
if dlCfg.S3Endpoint != "" || dlCfg.HTTPProxy != "" {
1883+
if dlCfg.S3Endpoint != "" {
1884+
secret += fmt.Sprintf(",\n\t\t\tENDPOINT '%s'", dlCfg.S3Endpoint)
18581885
}
1886+
urlStyle, useSSL := resolveS3SecretTransport(dlCfg)
18591887
secret += fmt.Sprintf(",\n\t\t\tURL_STYLE '%s'", urlStyle)
1860-
1861-
useSSL := "false"
1862-
if dlCfg.S3UseSSL {
1863-
useSSL = "true"
1864-
}
18651888
secret += fmt.Sprintf(",\n\t\t\tUSE_SSL %s", useSSL)
18661889
}
18671890

@@ -1919,17 +1942,12 @@ func buildAWSSdkSecret(ctx context.Context, dlCfg DuckLakeConfig) (string, error
19191942
secret += fmt.Sprintf(",\n\t\t\tSESSION_TOKEN '%s'", creds.SessionToken)
19201943
}
19211944

1922-
if dlCfg.S3Endpoint != "" {
1923-
secret += fmt.Sprintf(",\n\t\t\tENDPOINT '%s'", dlCfg.S3Endpoint)
1924-
urlStyle := dlCfg.S3URLStyle
1925-
if urlStyle == "" {
1926-
urlStyle = "path"
1945+
if dlCfg.S3Endpoint != "" || dlCfg.HTTPProxy != "" {
1946+
if dlCfg.S3Endpoint != "" {
1947+
secret += fmt.Sprintf(",\n\t\t\tENDPOINT '%s'", dlCfg.S3Endpoint)
19271948
}
1949+
urlStyle, useSSL := resolveS3SecretTransport(dlCfg)
19281950
secret += fmt.Sprintf(",\n\t\t\tURL_STYLE '%s'", urlStyle)
1929-
useSSL := "false"
1930-
if dlCfg.S3UseSSL {
1931-
useSSL = "true"
1932-
}
19331951
secret += fmt.Sprintf(",\n\t\t\tUSE_SSL %s", useSSL)
19341952
}
19351953

0 commit comments

Comments
 (0)