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: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
10 changes: 7 additions & 3 deletions internal/cmd/gmail_read_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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)
}
})
}
Expand Down
10 changes: 7 additions & 3 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 19 additions & 13 deletions internal/googleapi/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -123,15 +129,15 @@ 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)
}

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)
Expand Down
24 changes: 2 additions & 22 deletions internal/googleapi/gmail_read_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
88 changes: 15 additions & 73 deletions internal/googleapi/gmail_read_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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},
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}
66 changes: 21 additions & 45 deletions internal/googleapi/google_read_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -69,37 +55,27 @@ 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
}

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),
},
}

Expand Down
Loading