Skip to content

Allow clients to select ALPN protocols instead of forcing HTTP/2 - #159

Closed
sruthi-talluri wants to merge 1 commit into
google:mainfrom
sruthi-talluri:alpn-next-protos
Closed

sruthi-talluri wants to merge 1 commit into
google:mainfrom
sruthi-talluri:alpn-next-protos

Conversation

@sruthi-talluri

Copy link
Copy Markdown
Contributor

GetTLSConfigurationForClient has hardcoded NextProtos to {"h2"}, which was added before when s2a-go was only used for gRPC. The HTTP entry points added later, NewTLSClientConfigFactory and NewS2ADialTLSContextFunc, inherited that default, so an HTTP/1.1 client gets a connection that has negotiated h2 via ALPN. net/http then writes HTTP/1.1 onto it and the first response fails to parse:

  net/http: HTTP/1.x transport connection broken: malformed HTTP response
  "\x00\x00$\x04\x00\x00\x00\x00\x00\x00\x05\x00\x10..."

which is an HTTP/2 SETTINGS frame. There is currently no way to avoid this other than rebuilding the tls.Config by hand after Build returns.

This adds ClientOptions.NextProtos, applied whenever a client tls.Config is built, and TLSClientConfigOptions.NextProtos as a per-Build override. NewS2ADialTLSContextFunc goes through NewTLSClientConfigFactory, so it picks the option.

Behavior is unchanged when neither is set: nextProtosOrDefault falls back to {"h2"}. The gRPC ClientHandshake path in internal/v2/s2av2.go passes nil explicitly, since that transport requires HTTP/2 and must not be overridable. The returned slice is copied so a later mutation by the caller cannot alter a live tls.Config.

GetTLSConfigurationForClient has hardcoded NextProtos to {"h2"} since google#72,
which was added in 2022 when s2a-go was only used for gRPC. The HTTP entry
points added later, NewTLSClientConfigFactory and NewS2ADialTLSContextFunc,
inherited that default, so an HTTP/1.1 client gets a connection that has
negotiated h2 via ALPN. net/http then writes HTTP/1.1 onto it and the first
response fails to parse:

  net/http: HTTP/1.x transport connection broken: malformed HTTP response
  "\x00\x00$\x04\x00\x00\x00\x00\x00\x00\x05\x00\x10..."

which is an HTTP/2 SETTINGS frame. There is currently no way to avoid this
other than rebuilding the tls.Config by hand after Build returns.

This adds ClientOptions.NextProtos, applied whenever a client tls.Config is
built, and TLSClientConfigOptions.NextProtos as a per-Build override.
NewS2ADialTLSContextFunc goes through NewTLSClientConfigFactory, so it picks
the option up as well.

Behavior is unchanged when neither is set: nextProtosOrDefault falls back to
{"h2"}. The gRPC ClientHandshake path in internal/v2/s2av2.go passes nil
explicitly, since that transport requires HTTP/2 and must not be overridable.
The returned slice is copied so a later mutation by the caller cannot alter a
live tls.Config.
@sruthi-talluri
sruthi-talluri marked this pull request as draft September 18, 2026 04:04
@sruthi-talluri
sruthi-talluri marked this pull request as ready for review September 18, 2026 04:04
sruthi-talluri added a commit to sruthi-talluri/gcsfuse that referenced this pull request Sep 18, 2026
v0.1.10 includes 489047f, "Client TLS configs should plumb identity to the
S2A server", which fixes LocalIdentity being dropped on the client TLS config
path. GCSFuse builds its http1 dialer through s2a.NewTLSClientConfigFactory,
so on v0.1.9 a delegated SPIFFE identity never reached the S2A server and the
handshake used the host identity instead.

The ALPN workaround in storageutil.newS2ADialTLSContextForHTTP1 is still
required. v0.1.10 continues to hardcode the client TLS config's NextProtos to
{"h2"}; google/s2a-go#159 makes that selectable and
the workaround can be removed once it ships.

Verified: go build ./... and go test -count=1 ./internal/storage/... ./cfg/...
both pass.
Comment on lines +46 to +48
// handshake. S2A has historically offered HTTP/2 only, which is required by
// the gRPC transport, so that remains the default when no protocols are
// requested.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We can remove this second sentence

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #161

if len(nextProtos) == 0 {
return []string{h2}
}
return append([]string(nil), nextProtos...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be replaced with slices.Clone(nextProtos)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #161

Comment thread s2a.go
// NextProtos is the list of ALPN protocols offered during the TLS
// handshake. If empty, ClientOptions.NextProtos is used; if that is also
// empty, HTTP/2 ("h2") is offered.
NextProtos []string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NextProtos param is added in TLSClientConfigOptions, s2aTLSClientConfigFactory and ClientOptions (in s2a_options.go).

Can it just be in TLSClientConfigOptions?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need NextProtos in ClientOptions because NewS2ADialTLSContextFunc takes *ClientOptions and invokes factory.Build(ctx, &TLSClientConfigOptions{ServerName: serverName}) internally on every dial in the background.

Callers configuring http.Transport via NewS2ADialTLSContextFunc (such as GCSFuse) do not call Build() directly, so without ClientOptions.NextProtos, dialer users would have no way to configure ALPN.

Having NextProtos in both places gives us the best of both worlds:

  1. ClientOptions.NextProtos: Sets the default for the factory and makes NewS2ADialTLSContextFunc work out of the box for HTTP/1.1 clients.
  2. TLSClientConfigOptions.NextProtos: Provides an optional per-Build() override when interacting with the low-level factory directly.

Following up in #161

Comment thread s2a.go
nextProtos []string
}

func (f *s2aTLSClientConfigFactory) Build(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we update s2a_test.go and s2a_e2e_test.go?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #161

@bargielavictor82-commits

Copy link
Copy Markdown

anushka567 added a commit to GoogleCloudPlatform/gcsfuse that referenced this pull request Sep 28, 2026
…rts (#5112)

* feat(cfg): add s2a-address and s2a-spiffe-id flags and configuration mappings

Add configuration fields and CLI flags for direct S2A authentication:
- s2a-address: daemon address for direct S2A mTLS authentication
- s2a-spiffe-id: local SPIFFE ID to assume for direct S2A authentication
- update config-gen parser to support alphanumeric tokens in flag names
- map flags to StorageClientConfig in cmd/legacy_main.go and add flag parsing unit tests

* feat(storage): support direct S2A authentication for HTTP storage client

Implement S2A mTLS transport dialer for HTTP storage client:
- construct S2A DialTLSContext using s2a.NewClientOptions when S2AAddress is configured
- support both HTTP/1 and HTTP/2 client protocols with S2A transport
- bypass token acquisition and TokenSource in CreateHttpClient when S2A is active
- add unit tests verifying S2A transport creation and mock S2A daemon dialer connection

* feat(storage): support direct S2A authentication for gRPC storage and control clients

Configure S2A transport credentials for gRPC storage and control clients:
- use s2av2.NewClientCredentials in createClientOptionForGRPCClient when S2AAddress is configured
- bypass standard token authentication when S2A credentials are used
- add unit tests verifying client options assembly for gRPC and HTTP handles with S2A

* fix(storage): refactor HTTP client creation and add S2A comments

* fix: correct ALPN and peer verification for the S2A transports

Two issues prevented an S2A-authenticated mount from working.

First, http1 could not talk to GCS at all. s2a-go hardcodes the client TLS
config's NextProtos to {"h2"}, and NewS2ADialTLSContextFunc returns a
*tls.Conn, so http.Transport records a negotiated protocol of "h2". The http1
branch deliberately sets TLSNextProto to an empty map to disable HTTP/2, so
the h2 handler lookup misses and the transport ends up speaking HTTP/1.1 over
an h2-negotiated connection. Every request then fails while parsing the
server's first HTTP/2 frame.

Build the TLS config for the http1 branch via s2a.NewTLSClientConfigFactory
and pin NextProtos to http/1.1 before dialing. The http2 branch continues to
use s2a.NewS2ADialTLSContextFunc unchanged.

Second, neither transport set VerificationMode on s2a.ClientOptions, so S2A
received UNSPECIFIED. The legacy S2A certificate verifier treats UNSPECIFIED
the same as CONNECT_TO_GOOGLE, so this went unnoticed, but the verifier used
on the delegated-SPIFFE path falls through to SPIFFE peer verification
instead and rejects the GCS server certificate with "Leaf SVID must have
exactly one URI SAN representing the SPIFFE ID". GCS is a Google endpoint
serving a WebPKI certificate, so ask for CONNECT_TO_GOOGLE explicitly on both
the HTTP and gRPC clients, which is what s2a.DefaultClientOptions already
does.

Verified end to end against s2a-go's fake S2A v2 server: http1 negotiates
HTTP/1.1 and succeeds, http2 still negotiates HTTP/2.0, and the previous
http1 path reproducibly fails. Also verified on an ACE staging host against
the local S2A daemon with a delegated SPIFFE identity: without the
verification mode the handshake fails with the SVID error above, and with it
the handshake completes and requests reach GCS.

* fix(storage): send an OAuth token alongside S2A

S2A secures the channel but conveys no IAM principal to GCS, so an access
token is still required. Both the HTTP and the gRPC path treated S2A and the
token as mutually exclusive, so an S2A mount sent no caller identity at all
and GCS rejected every request with:

  Unauthenticated: Anonymous caller does not have storage.objects.list access

storageutil/client.go: the oauth2.Transport wrapper was gated on
`!AnonymousAccess && S2AAddress == ""`. Drop the S2A term so the token is
attached whenever access is not anonymous.

storage_handle.go: createClientOptionForGRPCClient built the S2A transport
credentials and then appended option.WithoutAuthentication(), which suppressed
the per-RPC token. Split the function into two independent blocks, one that
selects the transport (S2A or default) and one that selects the identity
(anonymous, google lib auth, or token source), and drop
WithoutAuthentication() from the S2A path.

The two compose correctly in google-api-go-client: transport/grpc/dial.go
installs the default transport credentials first and appends the caller's
GRPCDialOpts last, so the S2A credentials win, while the token source is
installed separately as per-RPC credentials.

Tests: TestCreateHttpClientWithS2A_DialVerification now supplies a static
token source, since it previously failed the token fetch before reaching the
dial. Adds TestCreateHttpClientWithS2A_AttachesOAuthTransport and
TestCreateHttpClientWithAnonymousAccess_NoOAuthTransport, which assert the
RoundTripper chain directly rather than only checking for a nil error.

* fix(auth): send Metadata-Flavor: Google when fetching a token

proxyTokenSource.Token() issued a bare client.Get with no headers. The token
URL points at a GCE-metadata-compatible endpoint -- the metadata server
itself, or a proxy in front of it such as ACE's MDS Proxy -- and those servers
require the Metadata-Flavor: Google header. Requiring a non-standard header is
what prevents a browser or other naive HTTP client from being coerced into
fetching a token on an attacker's behalf, since a cross-origin request cannot
set one.

Because gcsfuse did not send it, ACE had to exempt the token endpoint from the
check, leaving an SSRF/CSRF hole open (b/553201361). Sending the header
unconditionally lets that exemption be removed, and is harmless against
endpoints that do not inspect it.

Build the request explicitly and set the header rather than using the
client.Get shorthand.

Tests: adds Test_NewTokenSourceFromURL_SendsMetadataFlavorHeader and
Test_NewTokenSourceFromURL_UnixSocket_SendsMetadataFlavorHeader, covering both
the TCP and the unix-socket transports, since the UDS path rewrites the
request URL and is worth asserting separately.

* chore(deps): upgrade github.com/google/s2a-go to v0.1.10

v0.1.10 includes 489047f, "Client TLS configs should plumb identity to the
S2A server", which fixes LocalIdentity being dropped on the client TLS config
path. GCSFuse builds its http1 dialer through s2a.NewTLSClientConfigFactory,
so on v0.1.9 a delegated SPIFFE identity never reached the S2A server and the
handshake used the host identity instead.

The ALPN workaround in storageutil.newS2ADialTLSContextForHTTP1 is still
required. v0.1.10 continues to hardcode the client TLS config's NextProtos to
{"h2"}; google/s2a-go#159 makes that selectable and
the workaround can be removed once it ships.

Verified: go build ./... and go test -count=1 ./internal/storage/... ./cfg/...
both pass.

* fix(cfg): update proto tags and fix proto Go field CamelCase mapping

* fix: pass baseDialer to newS2ADialTLSContextForHTTP1 to preserve custom dialer settings

* fix(storage): align anonymous transport behavior and remove redundant WithoutAuthentication from S2A

---------

Co-authored-by: Sruthi Talluri <sruthitalluri@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants