From 6f8cbb6ee90923787e58407e46b95e700b76c78a Mon Sep 17 00:00:00 2001 From: "Jason T. Greene" Date: Sat, 5 Sep 2026 01:22:02 -0500 Subject: [PATCH 1/2] gmail read proxy: present the caller bearer and accept the governed base The read-proxy transport (edd82683) assumed a loopback forwarder that attached the proxy credential on the way out: it refused any GOG_GMAIL_READ_PROXY_URL that is not a loopback IP and deleted Authorization on every request without adding anything in its place. Against a governed read proxy reached through an egress supervisor both assumptions fail: the proxy lives at a non-loopback name and it authenticates its caller with a bearer nothing on this path supplied. Reduce the transport to what only the client can do: build the Gmail service against the proxy origin without Google authentication, require GOG_ACCESS_TOKEN in read-proxy mode, and send it as the Authorization bearer on every proxied request. It is the caller's credential toward the proxy - a governed placeholder substituted in transit, or the proxy's static bearer - never a Google token. Everything else the transport enforced is dropped: the loopback-only origin, the method/path allowlist, the credential-header stripping, the query-credential check and the redirect refusal. Which destinations, methods and paths a request may reach is the read proxy's and the sandbox network policy's decision, applied to every process; this client holds no Google credential that could leak, and the HTTP client already drops Authorization on cross-host redirects. Co-Authored-By: Claude Opus 5 --- docs/spec.md | 2 +- internal/cmd/gmail_read_proxy_test.go | 10 ++- internal/cmd/root.go | 10 ++- internal/googleapi/factory.go | 32 +++++--- internal/googleapi/gmail_read_proxy.go | 24 +----- internal/googleapi/gmail_read_proxy_test.go | 88 ++++----------------- internal/googleapi/google_read_proxy.go | 67 +++++----------- 7 files changed, 72 insertions(+), 161 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index b78500ac3..b8bfdff55 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -176,7 +176,7 @@ Environment: - `GOG_ENABLE_COMMANDS_EXACT=calendar.events,gmail.search` (optional exact allowlist; dot paths allowed; parent paths do not allow children) - `GOG_DISABLE_COMMANDS=gmail.send,gmail.drafts.send` (optional denylist; dot paths allowed) - `GOG_GMAIL_NO_SEND=1` (block Gmail send operations) -- `GOG_GMAIL_READ_PROXY_URL=http://127.0.0.1:8080/` (route Gmail reads through an unauthenticated loopback proxy; only loopback IP origins are accepted, write methods and redirects are blocked, and OAuth credentials are never attached) +- `GOG_GMAIL_READ_PROXY_URL=https://host.containers.internal:18081/` (route Gmail reads through the governed read proxy without Google authentication; any HTTP(S) origin is accepted, and `GOG_ACCESS_TOKEN` is required and sent as the caller's bearer toward the proxy — a governed placeholder or the proxy's static credential, never a Google token. Which destinations, methods and paths are allowed is the proxy's and the sandbox policy's decision, not the client's) - `GOG_GMAIL_BASE_URL` remains a deprecated alias for `GOG_GMAIL_READ_PROXY_URL`; when both are set, `GOG_GMAIL_READ_PROXY_URL` wins - `config.json` can also set `keyring_backend` (JSON5; env vars take precedence) - `config.json` can also set `default_timezone` (IANA name or `UTC`) diff --git a/internal/cmd/gmail_read_proxy_test.go b/internal/cmd/gmail_read_proxy_test.go index 1669598a9..7f9fd319a 100644 --- a/internal/cmd/gmail_read_proxy_test.go +++ b/internal/cmd/gmail_read_proxy_test.go @@ -8,7 +8,7 @@ import ( "github.com/openclaw/gogcli/internal/app" ) -func TestGmailReadProxyCommandUsesNoAuthentication(t *testing.T) { +func TestGmailReadProxyCommandPresentsOnlyTheCallerBearer(t *testing.T) { for _, envName := range []string{"GOG_GMAIL_READ_PROXY_URL", "GOG_GMAIL_BASE_URL"} { t.Run(envName, func(t *testing.T) { requestSeen := make(chan *http.Request, 1) @@ -21,6 +21,7 @@ func TestGmailReadProxyCommandUsesNoAuthentication(t *testing.T) { t.Setenv("GOG_GMAIL_READ_PROXY_URL", "") t.Setenv("GOG_GMAIL_BASE_URL", "") t.Setenv(envName, server.URL) + t.Setenv("GOG_ACCESS_TOKEN", "openshell:resolve:gmail-read-proxy:0123") result := executeWithTestRuntime(t, []string{ "--json", "--account", "proxy@localhost", "gmail", "get", "message-1", @@ -30,8 +31,11 @@ func TestGmailReadProxyCommandUsesNoAuthentication(t *testing.T) { } request := <-requestSeen - if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("Authorization = %q, want empty", got) + // No Google OAuth happened (the account has no stored token); the + // request carries exactly the caller credential from the + // environment, which the governed proxy authenticates. + if got := request.Header.Get("Authorization"); got != "Bearer openshell:resolve:gmail-read-proxy:0123" { + t.Fatalf("Authorization = %q, want the caller bearer", got) } }) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1dd116263..97139a75c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -296,9 +296,13 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { } ctx = googleapi.WithAuthDependencies(ctx, authDependencies) composeRuntimeGoogleServices(runtime, googleapi.NewFactory(authDependencies, googleapi.FactoryOptions{ - GmailReadProxyURL: gmailReadProxyURLFromEnv(), - PhotosBaseURL: os.Getenv("GOG_PHOTOS_BASE_URL"), - PhotosPickerBaseURL: os.Getenv("GOG_PHOTOS_PICKER_BASE_URL"), + GmailReadProxyURL: gmailReadProxyURLFromEnv(), + // In read-proxy mode GOG_ACCESS_TOKEN is the caller credential toward + // the proxy (a governed placeholder or the proxy's static bearer), + // never a Google token. + GmailReadProxyBearer: directAccessToken(&cli.RootFlags), + PhotosBaseURL: os.Getenv("GOG_PHOTOS_BASE_URL"), + PhotosPickerBaseURL: os.Getenv("GOG_PHOTOS_PICKER_BASE_URL"), })) ctx = authclient.WithCredentialsReader(ctx, readCredentials) ctx = authclient.WithSecretsStoreOpener(ctx, openTokens) diff --git a/internal/googleapi/factory.go b/internal/googleapi/factory.go index 58ca0e788..82dc66b16 100644 --- a/internal/googleapi/factory.go +++ b/internal/googleapi/factory.go @@ -32,24 +32,30 @@ import ( ) type FactoryOptions struct { - GmailReadProxyURL string - PhotosBaseURL string - PhotosPickerBaseURL string + GmailReadProxyURL string + // GmailReadProxyBearer is the caller credential presented to the Gmail + // read proxy (GOG_ACCESS_TOKEN). Required whenever GmailReadProxyURL is + // set; never a Google token. + GmailReadProxyBearer string + PhotosBaseURL string + PhotosPickerBaseURL string } type Factory struct { - auth AuthDependencies - gmailReadProxyURL string - photosBaseURL string - photosPickerBaseURL string + auth AuthDependencies + gmailReadProxyURL string + gmailReadProxyBearer string + photosBaseURL string + photosPickerBaseURL string } func NewFactory(auth AuthDependencies, options FactoryOptions) Factory { return Factory{ - auth: auth, - gmailReadProxyURL: options.GmailReadProxyURL, - photosBaseURL: options.PhotosBaseURL, - photosPickerBaseURL: options.PhotosPickerBaseURL, + auth: auth, + gmailReadProxyURL: options.GmailReadProxyURL, + gmailReadProxyBearer: options.GmailReadProxyBearer, + photosBaseURL: options.PhotosBaseURL, + photosPickerBaseURL: options.PhotosPickerBaseURL, } } @@ -123,7 +129,7 @@ func (f Factory) Forms(ctx context.Context, account string) (*forms.Service, err func (f Factory) Gmail(ctx context.Context, account string) (*gmail.Service, error) { if f.gmailReadProxyURL != "" { - return newGmailReadProxy(ctx, f.gmailReadProxyURL) + return newGmailReadProxy(ctx, f.gmailReadProxyURL, f.gmailReadProxyBearer) } return NewGmail(f.withAuth(ctx), account) @@ -131,7 +137,7 @@ func (f Factory) Gmail(ctx context.Context, account string) (*gmail.Service, err func (f Factory) GmailDelete(ctx context.Context, account string) (*gmail.Service, error) { if f.gmailReadProxyURL != "" { - return newGmailReadProxy(ctx, f.gmailReadProxyURL) + return newGmailReadProxy(ctx, f.gmailReadProxyURL, f.gmailReadProxyBearer) } return NewGmailBatchDelete(f.withAuth(ctx), account) diff --git a/internal/googleapi/gmail_read_proxy.go b/internal/googleapi/gmail_read_proxy.go index cfe1c7551..06dbfde52 100644 --- a/internal/googleapi/gmail_read_proxy.go +++ b/internal/googleapi/gmail_read_proxy.go @@ -2,34 +2,14 @@ package googleapi import ( "context" - "errors" "fmt" - "net/http" - "strings" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" ) -var ( - errGmailReadProxyMethod = errors.New("gmail read proxy blocks request method") - errGmailReadProxyEndpoint = errors.New("gmail read proxy blocks request outside its configured endpoint") -) - -func allowGmailReadProxyRequest(request *http.Request) error { - if request.Method != http.MethodGet && request.Method != http.MethodHead { - return fmt.Errorf("%w: %s", errGmailReadProxyMethod, request.Method) - } - - if !strings.HasPrefix(request.URL.Path, "/gmail/v1/users/me/") { - return errGmailReadProxyEndpoint - } - - return nil -} - -func newGmailReadProxy(ctx context.Context, endpoint string) (*gmail.Service, error) { - client, normalized, err := newGoogleReadProxyClient(endpoint, allowGmailReadProxyRequest) +func newGmailReadProxy(ctx context.Context, endpoint, bearer string) (*gmail.Service, error) { + client, normalized, err := newGoogleReadProxyClient(endpoint, bearer) if err != nil { return nil, err } diff --git a/internal/googleapi/gmail_read_proxy_test.go b/internal/googleapi/gmail_read_proxy_test.go index bc264fe8b..af8cd4027 100644 --- a/internal/googleapi/gmail_read_proxy_test.go +++ b/internal/googleapi/gmail_read_proxy_test.go @@ -4,10 +4,7 @@ import ( "context" "net/http" "net/http/httptest" - "sync/atomic" "testing" - - "google.golang.org/api/gmail/v1" ) func TestNormalizeGoogleReadProxyURL(t *testing.T) { @@ -21,8 +18,9 @@ func TestNormalizeGoogleReadProxyURL(t *testing.T) { }{ {name: "IPv4", value: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/"}, {name: "IPv6", value: "https://[::1]:8443/", want: "https://[::1]:8443/"}, - {name: "hostname", value: "http://localhost:8080/", wantErr: true}, - {name: "remote", value: "https://proxy.example/", wantErr: true}, + {name: "hostname", value: "http://localhost:8080/", want: "http://localhost:8080/"}, + {name: "governed base", value: "https://host.containers.internal:18081", want: "https://host.containers.internal:18081/"}, + {name: "remote", value: "https://proxy.example/", want: "https://proxy.example/"}, {name: "path", value: "http://127.0.0.1:8080/proxy", wantErr: true}, {name: "credentials", value: "http://user:pass@127.0.0.1:8080/", wantErr: true}, {name: "query", value: "http://127.0.0.1:8080/?token=value", wantErr: true}, @@ -54,7 +52,7 @@ func TestNormalizeGoogleReadProxyURL(t *testing.T) { } } -func TestGmailReadProxySendsNoCredentials(t *testing.T) { +func TestGmailReadProxyPresentsTheCallerBearer(t *testing.T) { t.Parallel() requestSeen := make(chan *http.Request, 1) @@ -66,86 +64,30 @@ func TestGmailReadProxySendsNoCredentials(t *testing.T) { })) t.Cleanup(server.Close) - service, err := newGmailReadProxy(context.Background(), server.URL) + service, err := newGmailReadProxy(context.Background(), server.URL, " openshell:resolve:gmail-read-proxy:0123 ") if err != nil { t.Fatalf("newGmailReadProxy: %v", err) } call := service.Users.Messages.Get("me", "message-1") - call.Header().Set("Authorization", "Bearer must-not-leak") - call.Header().Set("Cookie", "session=must-not-leak") - call.Header().Set("Proxy-Authorization", "Basic must-not-leak") - call.Header().Set("X-Goog-Api-Key", "must-not-leak") + call.Header().Set("Authorization", "Bearer set-by-the-process") if _, err := call.Do(); err != nil { t.Fatalf("Messages.Get: %v", err) } - request := <-requestSeen - for _, name := range googleReadProxyCredentialHeaders { - if got := request.Header.Get(name); got != "" { - t.Errorf("%s = %q, want empty", name, got) - } + // The caller credential toward the proxy is what the proxy sees, whatever + // the process tried to attach. + if got := (<-requestSeen).Header.Get("Authorization"); got != "Bearer openshell:resolve:gmail-read-proxy:0123" { + t.Errorf("Authorization = %q, want the caller bearer", got) } } -func TestGmailReadProxyRefusesRedirects(t *testing.T) { +func TestGmailReadProxyRequiresTheCallerBearer(t *testing.T) { t.Parallel() - var redirectedRequests atomic.Int32 - requestSeen := make(chan *http.Request, 1) - redirected := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - redirectedRequests.Add(1) - })) - t.Cleanup(redirected.Close) - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requestSeen <- request.Clone(request.Context()) - - writer.Header().Set("Location", redirected.URL) - writer.WriteHeader(http.StatusFound) - })) - t.Cleanup(server.Close) - - service, err := newGmailReadProxy(context.Background(), server.URL) - if err != nil { - t.Fatalf("newGmailReadProxy: %v", err) - } - call := service.Users.Messages.Get("me", "message-1") - call.Header().Set("Authorization", "Bearer must-not-leak") - - if _, err := call.Do(); err == nil { - t.Fatal("Messages.Get succeeded through a redirect") - } - - if got := (<-requestSeen).Header.Get("Authorization"); got != "" { - t.Fatalf("redirecting endpoint received Authorization %q, want empty", got) - } - - if got := redirectedRequests.Load(); got != 0 { - t.Fatalf("redirect target received %d requests, want 0", got) - } -} - -func TestGmailReadProxyBlocksWritesBeforeNetwork(t *testing.T) { - t.Parallel() - - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - requests.Add(1) - })) - t.Cleanup(server.Close) - - service, err := newGmailReadProxy(context.Background(), server.URL) - if err != nil { - t.Fatalf("newGmailReadProxy: %v", err) - } - - err = service.Users.Messages.BatchDelete("me", &gmail.BatchDeleteMessagesRequest{Ids: []string{"message-1"}}).Do() - if err == nil { - t.Fatal("BatchDelete succeeded through a read proxy") - } - - if got := requests.Load(); got != 0 { - t.Fatalf("proxy received %d write requests, want 0", got) + for _, bearer := range []string{"", " "} { + if _, err := newGmailReadProxy(context.Background(), "http://127.0.0.1:18081/", bearer); err == nil { + t.Fatalf("newGmailReadProxy(bearer=%q) succeeded, want error", bearer) + } } } diff --git a/internal/googleapi/google_read_proxy.go b/internal/googleapi/google_read_proxy.go index 2c85556bc..fc921269a 100644 --- a/internal/googleapi/google_read_proxy.go +++ b/internal/googleapi/google_read_proxy.go @@ -3,53 +3,39 @@ package googleapi import ( "errors" "fmt" - "net" "net/http" "net/url" "strings" ) var ( - errGoogleReadProxyEndpoint = errors.New("google read proxy blocks request outside its configured endpoint") - errGoogleReadProxyCredentials = errors.New("google read proxy blocks credentials in the request URL") errGoogleReadProxyAbsoluteURL = errors.New("GOG_GMAIL_READ_PROXY_URL must be an absolute URL") errGoogleReadProxyScheme = errors.New("GOG_GMAIL_READ_PROXY_URL must use HTTP or HTTPS") - errGoogleReadProxyLoopback = errors.New("GOG_GMAIL_READ_PROXY_URL must use a loopback IP address") errGoogleReadProxyOrigin = errors.New("GOG_GMAIL_READ_PROXY_URL must be an origin without credentials, path, query, or fragment") + errGoogleReadProxyBearer = errors.New("GOG_GMAIL_READ_PROXY_URL requires GOG_ACCESS_TOKEN (the caller credential toward the read proxy)") ) -var googleReadProxyCredentialHeaders = []string{ - "Authorization", - "Cookie", - "Proxy-Authorization", - "X-Goog-Api-Key", -} - +// googleReadProxyTransport presents the caller's credential toward the read +// proxy on every request. That credential (GOG_ACCESS_TOKEN) is not a Google +// token: in a governed sandbox it is an OpenShell provider placeholder the +// supervisor substitutes in transit, otherwise the proxy's static bearer. +// The proxy authenticates the caller with it and holds the real Google +// credential itself. +// +// Nothing else is enforced here. Which destinations, methods and paths a +// request may reach is the read proxy's and the sandbox network policy's +// decision, applied to every process; this client holds no Google +// credential that could leak, and the HTTP client already drops +// Authorization on cross-host redirects. type googleReadProxyTransport struct { - base http.RoundTripper - origin string - allowRequest func(*http.Request) error + base http.RoundTripper + bearer string } func (t googleReadProxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if request.URL.Scheme+"://"+request.URL.Host != t.origin { - return nil, errGoogleReadProxyEndpoint - } - - if request.URL.Query().Has("access_token") || request.URL.Query().Has("key") { - return nil, errGoogleReadProxyCredentials - } - - if err := t.allowRequest(request); err != nil { - return nil, err - } - forwarded := request.Clone(request.Context()) forwarded.Header = request.Header.Clone() - - for _, name := range googleReadProxyCredentialHeaders { - forwarded.Header.Del(name) - } + forwarded.Header.Set("Authorization", "Bearer "+t.bearer) response, err := t.base.RoundTrip(forwarded) if err != nil { @@ -69,11 +55,6 @@ func normalizeGoogleReadProxyURL(value string) (string, error) { return "", errGoogleReadProxyScheme } - ip := net.ParseIP(parsed.Hostname()) - if ip == nil || !ip.IsLoopback() { - return "", errGoogleReadProxyLoopback - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { return "", errGoogleReadProxyOrigin } @@ -81,25 +62,19 @@ func normalizeGoogleReadProxyURL(value string) (string, error) { return parsed.Scheme + "://" + parsed.Host + "/", nil } -func newGoogleReadProxyClient(endpoint string, allowRequest func(*http.Request) error) (*http.Client, string, error) { +func newGoogleReadProxyClient(endpoint, bearer string) (*http.Client, string, error) { normalized, err := normalizeGoogleReadProxyURL(endpoint) if err != nil { return nil, "", err } - - parsed, err := url.Parse(normalized) - if err != nil { - return nil, "", fmt.Errorf("parse normalized Google read proxy URL: %w", err) + if strings.TrimSpace(bearer) == "" { + return nil, "", errGoogleReadProxyBearer } client := &http.Client{ Transport: googleReadProxyTransport{ - base: newBaseTransport(), - origin: parsed.Scheme + "://" + parsed.Host, - allowRequest: allowRequest, - }, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse + base: newBaseTransport(), + bearer: strings.TrimSpace(bearer), }, } From fdaad5df8020b058a103bd846715ac9b2015e8e8 Mon Sep 17 00:00:00 2001 From: "Jason T. Greene" Date: Sat, 5 Sep 2026 01:48:30 -0500 Subject: [PATCH 2/2] gmail read proxy: satisfy wsl blank-line lint Co-Authored-By: Claude Opus 5 --- internal/googleapi/google_read_proxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/googleapi/google_read_proxy.go b/internal/googleapi/google_read_proxy.go index fc921269a..6eef70476 100644 --- a/internal/googleapi/google_read_proxy.go +++ b/internal/googleapi/google_read_proxy.go @@ -67,6 +67,7 @@ func newGoogleReadProxyClient(endpoint, bearer string) (*http.Client, string, er if err != nil { return nil, "", err } + if strings.TrimSpace(bearer) == "" { return nil, "", errGoogleReadProxyBearer }