From edd82683c3293e49261d7355f164a5e2ac1f774f Mon Sep 17 00:00:00 2001 From: sallyom Date: Mon, 24 Aug 2026 16:56:41 -0400 Subject: [PATCH] feat(gmail): add safe read proxy transport Signed-off-by: sallyom --- docs/spec.md | 2 + internal/cmd/gmail_read_proxy_test.go | 51 +++++++ internal/cmd/root.go | 8 ++ internal/googleapi/factory.go | 11 ++ internal/googleapi/gmail_read_proxy.go | 48 +++++++ internal/googleapi/gmail_read_proxy_test.go | 151 ++++++++++++++++++++ internal/googleapi/google_read_proxy.go | 107 ++++++++++++++ 7 files changed, 378 insertions(+) create mode 100644 internal/cmd/gmail_read_proxy_test.go create mode 100644 internal/googleapi/gmail_read_proxy.go create mode 100644 internal/googleapi/gmail_read_proxy_test.go create mode 100644 internal/googleapi/google_read_proxy.go diff --git a/docs/spec.md b/docs/spec.md index 459c8d28e..b78500ac3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -176,6 +176,8 @@ 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_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`) - `config.json` can also set `places_api_key` (or use `GOG_PLACES_API_KEY` / `GOOGLE_PLACES_API_KEY`) for Calendar Places lookups. diff --git a/internal/cmd/gmail_read_proxy_test.go b/internal/cmd/gmail_read_proxy_test.go new file mode 100644 index 000000000..1669598a9 --- /dev/null +++ b/internal/cmd/gmail_read_proxy_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/openclaw/gogcli/internal/app" +) + +func TestGmailReadProxyCommandUsesNoAuthentication(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) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestSeen <- request.Clone(request.Context()) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"id":"message-1"}`)) + })) + t.Cleanup(server.Close) + t.Setenv("GOG_GMAIL_READ_PROXY_URL", "") + t.Setenv("GOG_GMAIL_BASE_URL", "") + t.Setenv(envName, server.URL) + + result := executeWithTestRuntime(t, []string{ + "--json", "--account", "proxy@localhost", "gmail", "get", "message-1", + }, &app.Runtime{ServicesManaged: true}) + if result.err != nil { + t.Fatalf("gmail get: %v\nstderr=%s", result.err, result.stderr) + } + + request := <-requestSeen + if got := request.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty", got) + } + }) + } +} + +func TestGmailReadProxyURLFromEnv(t *testing.T) { + t.Setenv("GOG_GMAIL_READ_PROXY_URL", "http://127.0.0.1:18079/") + t.Setenv("GOG_GMAIL_BASE_URL", "http://127.0.0.1:28079/") + if got := gmailReadProxyURLFromEnv(); got != "http://127.0.0.1:18079/" { + t.Fatalf("gmailReadProxyURLFromEnv() = %q, want new variable", got) + } + + t.Setenv("GOG_GMAIL_READ_PROXY_URL", "") + if got := gmailReadProxyURLFromEnv(); got != "http://127.0.0.1:28079/" { + t.Fatalf("gmailReadProxyURLFromEnv() = %q, want legacy fallback", got) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 203b3967d..1dd116263 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -296,6 +296,7 @@ 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"), })) @@ -359,6 +360,13 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { return err } +func gmailReadProxyURLFromEnv() string { + if value := os.Getenv("GOG_GMAIL_READ_PROXY_URL"); value != "" { + return value + } + return os.Getenv("GOG_GMAIL_BASE_URL") +} + func rewriteHelpArgs(args []string) []string { for i, arg := range args { if arg == "--" { diff --git a/internal/googleapi/factory.go b/internal/googleapi/factory.go index a169774c1..58ca0e788 100644 --- a/internal/googleapi/factory.go +++ b/internal/googleapi/factory.go @@ -32,12 +32,14 @@ import ( ) type FactoryOptions struct { + GmailReadProxyURL string PhotosBaseURL string PhotosPickerBaseURL string } type Factory struct { auth AuthDependencies + gmailReadProxyURL string photosBaseURL string photosPickerBaseURL string } @@ -45,6 +47,7 @@ type Factory struct { func NewFactory(auth AuthDependencies, options FactoryOptions) Factory { return Factory{ auth: auth, + gmailReadProxyURL: options.GmailReadProxyURL, photosBaseURL: options.PhotosBaseURL, photosPickerBaseURL: options.PhotosPickerBaseURL, } @@ -119,10 +122,18 @@ 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 NewGmail(f.withAuth(ctx), account) } func (f Factory) GmailDelete(ctx context.Context, account string) (*gmail.Service, error) { + if f.gmailReadProxyURL != "" { + return newGmailReadProxy(ctx, f.gmailReadProxyURL) + } + return NewGmailBatchDelete(f.withAuth(ctx), account) } diff --git a/internal/googleapi/gmail_read_proxy.go b/internal/googleapi/gmail_read_proxy.go new file mode 100644 index 000000000..cfe1c7551 --- /dev/null +++ b/internal/googleapi/gmail_read_proxy.go @@ -0,0 +1,48 @@ +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) + if err != nil { + return nil, err + } + + service, err := gmail.NewService( + ctx, + option.WithEndpoint(normalized), + option.WithHTTPClient(client), + option.WithoutAuthentication(), + ) + if err != nil { + return nil, fmt.Errorf("create Gmail read proxy service: %w", err) + } + + return service, nil +} diff --git a/internal/googleapi/gmail_read_proxy_test.go b/internal/googleapi/gmail_read_proxy_test.go new file mode 100644 index 000000000..bc264fe8b --- /dev/null +++ b/internal/googleapi/gmail_read_proxy_test.go @@ -0,0 +1,151 @@ +package googleapi + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "google.golang.org/api/gmail/v1" +) + +func TestNormalizeGoogleReadProxyURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + wantErr bool + }{ + {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: "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}, + {name: "fragment", value: "http://127.0.0.1:8080/#fragment", wantErr: true}, + {name: "scheme", value: "ftp://127.0.0.1/", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := normalizeGoogleReadProxyURL(test.value) + if test.wantErr { + if err == nil { + t.Fatalf("normalizeGoogleReadProxyURL(%q) = %q, want error", test.value, got) + } + + return + } + + if err != nil { + t.Fatalf("normalizeGoogleReadProxyURL(%q): %v", test.value, err) + } + + if got != test.want { + t.Fatalf("normalizeGoogleReadProxyURL(%q) = %q, want %q", test.value, got, test.want) + } + }) + } +} + +func TestGmailReadProxySendsNoCredentials(t *testing.T) { + t.Parallel() + + requestSeen := make(chan *http.Request, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestSeen <- request.Clone(request.Context()) + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"id":"message-1"}`)) + })) + 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") + 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") + + 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) + } + } +} + +func TestGmailReadProxyRefusesRedirects(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) + } +} diff --git a/internal/googleapi/google_read_proxy.go b/internal/googleapi/google_read_proxy.go new file mode 100644 index 000000000..2c85556bc --- /dev/null +++ b/internal/googleapi/google_read_proxy.go @@ -0,0 +1,107 @@ +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") +) + +var googleReadProxyCredentialHeaders = []string{ + "Authorization", + "Cookie", + "Proxy-Authorization", + "X-Goog-Api-Key", +} + +type googleReadProxyTransport struct { + base http.RoundTripper + origin string + allowRequest func(*http.Request) error +} + +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) + } + + response, err := t.base.RoundTrip(forwarded) + if err != nil { + return nil, fmt.Errorf("google read proxy request: %w", err) + } + + return response, nil +} + +func normalizeGoogleReadProxyURL(value string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errGoogleReadProxyAbsoluteURL + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + 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 + } + + return parsed.Scheme + "://" + parsed.Host + "/", nil +} + +func newGoogleReadProxyClient(endpoint string, allowRequest func(*http.Request) error) (*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) + } + + client := &http.Client{ + Transport: googleReadProxyTransport{ + base: newBaseTransport(), + origin: parsed.Scheme + "://" + parsed.Host, + allowRequest: allowRequest, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return client, normalized, nil +}