Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions internal/cmd/gmail_read_proxy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}))
Expand Down Expand Up @@ -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 == "--" {
Expand Down
11 changes: 11 additions & 0 deletions internal/googleapi/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ import (
)

type FactoryOptions struct {
GmailReadProxyURL string
PhotosBaseURL string
PhotosPickerBaseURL string
}

type Factory struct {
auth AuthDependencies
gmailReadProxyURL 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,
}
Expand Down Expand Up @@ -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)
}

Expand Down
48 changes: 48 additions & 0 deletions internal/googleapi/gmail_read_proxy.go
Original file line number Diff line number Diff line change
@@ -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
}
151 changes: 151 additions & 0 deletions internal/googleapi/gmail_read_proxy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading