Skip to content
Open
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
852 changes: 445 additions & 407 deletions clientlibrary/service/service.pb.go

Large diffs are not rendered by default.

64 changes: 61 additions & 3 deletions core/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package core

import (
"context"
"errors"
"fmt"

"github.com/anyproto/any-sync/net"

Expand All @@ -12,6 +14,7 @@ import (
"github.com/anyproto/anytype-heart/pb"
"github.com/anyproto/anytype-heart/space/spacecore/storage/migrator"
"github.com/anyproto/anytype-heart/util/grpcprocess"
"github.com/anyproto/anytype-heart/util/localorigin"
)

func (mw *Middleware) AccountCreate(cctx context.Context, req *pb.RpcAccountCreateRequest) *pb.RpcAccountCreateResponse {
Expand Down Expand Up @@ -266,6 +269,7 @@ func (mw *Middleware) AccountLocalLinkNewChallenge(ctx context.Context, request
challengeId, err := mw.applicationService.LinkLocalStartNewChallenge(request.Scope, &info)
code := mapErrorCode(err,
errToCode(session.ErrTooManyChallengeRequests, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS),
errToCode(session.ErrTooManyCallerChallengeRequests, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS),
errToCode(session.ErrChallengeAttemptsExceeded, pb.RpcAccountLocalLinkNewChallengeResponseError_TOO_MANY_REQUESTS),
errToCode(application.ErrApplicationIsNotRunning, pb.RpcAccountLocalLinkNewChallengeResponseError_ACCOUNT_IS_NOT_RUNNING),
)
Expand All @@ -279,6 +283,56 @@ func (mw *Middleware) AccountLocalLinkNewChallenge(ctx context.Context, request
}
}

// AccountLocalLinkApproveChallenge carries the user's answer to a pairing
// prompt. It is the only place a challenge code is minted, and the code is
// returned here rather than broadcast, so it reaches only the session that
// approved.
//
// This method must stay out of both noAuthMethods and limitedScopeMethods in
// core/auth.go: falling through both is what restricts it to AccountAuth_Full,
// i.e. the desktop UI. Listing it in noAuthMethods would let any local process
// approve its own pairing.
func (mw *Middleware) AccountLocalLinkApproveChallenge(ctx context.Context, req *pb.RpcAccountLocalLinkApproveChallengeRequest) *pb.RpcAccountLocalLinkApproveChallengeResponse {
err := mw.rejectBrowserCaller(ctx)
if err == nil {
var challenge string
challenge, _, err = mw.applicationService.LinkLocalApproveChallenge(req.ProcessPath, req.Origin, req.Allow)
if err == nil {
return &pb.RpcAccountLocalLinkApproveChallengeResponse{
Challenge: challenge,
Error: &pb.RpcAccountLocalLinkApproveChallengeResponseError{
Code: pb.RpcAccountLocalLinkApproveChallengeResponseError_NULL,
},
}
}
}
code := mapErrorCode(err,
errToCode(session.ErrNoPendingChallenge, pb.RpcAccountLocalLinkApproveChallengeResponseError_NO_PENDING_CHALLENGE),
errToCode(errBrowserCallerNotAllowed, pb.RpcAccountLocalLinkApproveChallengeResponseError_BAD_INPUT),
errToCode(application.ErrApplicationIsNotRunning, pb.RpcAccountLocalLinkApproveChallengeResponseError_ACCOUNT_IS_NOT_RUNNING),
)
return &pb.RpcAccountLocalLinkApproveChallengeResponse{
Error: &pb.RpcAccountLocalLinkApproveChallengeResponseError{
Code: code,
Description: getErrorDescription(err),
},
}
}

// errBrowserCallerNotAllowed rejects a request that came from a browser context.
var errBrowserCallerNotAllowed = errors.New("this method cannot be called from a browser")

// rejectBrowserCaller refuses callers that carry an Origin header. Approving a
// pairing is a desktop-UI action; the gRPC-Web proxy trusts the Webclipper
// extension's origins, so a browser context can reach the RPC surface and must
// be turned away here even when it holds a valid token.
func (mw *Middleware) rejectBrowserCaller(ctx context.Context) error {
if origin := localorigin.OriginFromContext(ctx); origin != "" {
return fmt.Errorf("%w: origin %q", errBrowserCallerNotAllowed, origin)
}
return nil
}

func (mw *Middleware) AccountLocalLinkSolveChallenge(_ context.Context, req *pb.RpcAccountLocalLinkSolveChallengeRequest) *pb.RpcAccountLocalLinkSolveChallengeResponse {
token, appKey, err := mw.applicationService.LinkLocalSolveChallenge(req)
code := mapErrorCode(err,
Expand Down Expand Up @@ -341,13 +395,17 @@ func (mw *Middleware) AccountLocalLinkRevokeApp(_ context.Context, req *pb.RpcAc
}
}

func getClientInfo(ctx context.Context) pb.EventAccountLinkChallengeClientInfo {
func getClientInfo(ctx context.Context) pb.EventAccountLinkApprovalRequestClientInfo {
// Browser callers reach the JSON API over HTTP and have no process to
// inspect; the Origin header is what names them instead.
origin := localorigin.OriginFromContext(ctx)
info, ok := grpcprocess.FromContext(ctx)
if !ok {
return pb.EventAccountLinkChallengeClientInfo{}
return pb.EventAccountLinkApprovalRequestClientInfo{Origin: origin}
}
return pb.EventAccountLinkChallengeClientInfo{
return pb.EventAccountLinkApprovalRequestClientInfo{
ProcessName: info.Name,
ProcessPath: info.Path,
Origin: origin,
}
}
44 changes: 44 additions & 0 deletions core/account_link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package core

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anyproto/anytype-heart/pb"
"github.com/anyproto/anytype-heart/util/localorigin"
)

// TestAccountLocalLinkApproveChallenge_RefusesBrowserCallers checks the second
// half of the approval flow's access control. Scope handling in Authorize keeps
// out unauthenticated and limited callers; this keeps out browser contexts,
// which the gRPC-Web proxy admits for the Webclipper's extension origins.
//
// The Middleware here has no applicationService: reaching it would panic, so the
// test also pins that the origin check runs before any work.
func TestAccountLocalLinkApproveChallenge_RefusesBrowserCallers(t *testing.T) {
for _, origin := range []string{
"chrome-extension://jbnammhjiplhpjfncnlejjjejghimdkf", // the trusted Webclipper is still a browser
"http://localhost:3000",
"https://evil.com",
} {
t.Run(origin, func(t *testing.T) {
// given
mw := &Middleware{}
ctx := localorigin.WithOrigin(context.Background(), origin)

// when
resp := mw.AccountLocalLinkApproveChallenge(ctx, &pb.RpcAccountLocalLinkApproveChallengeRequest{
Origin: origin,
Allow: true,
})

// then
require.NotNil(t, resp.Error)
assert.Equal(t, pb.RpcAccountLocalLinkApproveChallengeResponseError_BAD_INPUT, resp.Error.Code)
assert.Empty(t, resp.Challenge, "a refused call must never carry a code")
})
}
}
40 changes: 40 additions & 0 deletions core/api/server/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"net"
"net/http"
"strings"

Expand All @@ -16,6 +17,7 @@ import (
"github.com/anyproto/anytype-heart/core/event"
"github.com/anyproto/anytype-heart/pb"
"github.com/anyproto/anytype-heart/pkg/lib/logging"
"github.com/anyproto/anytype-heart/util/grpcprocess"
"github.com/anyproto/anytype-heart/util/localorigin"
)

Expand Down Expand Up @@ -48,6 +50,12 @@ func ensureMetadataHeader() gin.HandlerFunc {
func ensureTrustedOrigin(policy *localorigin.Policy) gin.HandlerFunc {
return func(c *gin.Context) {
if policy.AllowRequest(c.Request) {
// Carry the accepted origin so the pairing challenge can name the
// caller. Browsers set this header themselves, so unlike the
// app_name in the body it is not the caller's to choose.
if origin := c.GetHeader("Origin"); origin != "" {
c.Request = c.Request.WithContext(localorigin.WithOrigin(c.Request.Context(), origin))
}
c.Next()
return
}
Expand All @@ -57,6 +65,38 @@ func ensureTrustedOrigin(policy *localorigin.Policy) gin.HandlerFunc {
}
}

// ensureClientProcess resolves the process on the other end of the connection
// so the pairing dialog can name a native caller, which has no Origin to show.
// For a browser it names the browser itself, which pairs usefully with the
// extension origin.
//
// It is deliberately not global middleware: resolving walks the machine's TCP
// table (lsof on macOS), which is far too expensive to do per API request.
// Mount it only where a human is about to be asked to trust the caller.
func ensureClientProcess() gin.HandlerFunc {
return func(c *gin.Context) {
host, port, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
c.Next()
return
}
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
// Only loopback peers are on this machine to be resolved at all.
c.Next()
return
}
info, err := grpcprocess.ResolveProcess(host, port)
if err != nil {
// Best effort: the dialog falls back to the origin and app name.
log.With("error", err).Debugf("could not resolve client process for pairing")
c.Next()
return
}
c.Request = c.Request.WithContext(grpcprocess.WithProcessInfo(c.Request.Context(), info))
c.Next()
}
}

// ensureAuthenticated is a middleware that ensures the request is authenticated.
func (srv *Server) ensureAuthenticated(mw apicore.ClientCommands) gin.HandlerFunc {
return func(c *gin.Context) {
Expand Down
4 changes: 3 additions & 1 deletion core/api/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ func (srv *Server) registerDocumentationRoutes(router *gin.Engine, openapiYAML [
func (srv *Server) registerAuthRoutes(router *gin.Engine) {
authGroup := router.Group("/v1")
{
authGroup.POST("/auth/challenges", handler.CreateChallengeHandler(srv.service))
// Only this route asks a human to trust the caller, so it is the only
// one that pays for resolving the caller's process.
authGroup.POST("/auth/challenges", ensureClientProcess(), handler.CreateChallengeHandler(srv.service))
authGroup.POST("/auth/api_keys", handler.CreateApiKeyHandler(srv.service))
}
}
Expand Down
104 changes: 104 additions & 0 deletions core/api/server/router_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package server

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gin-gonic/gin"
"github.com/gogo/protobuf/types"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/anyproto/anytype-heart/pb"
"github.com/anyproto/anytype-heart/util/grpcprocess"
"github.com/anyproto/anytype-heart/util/localorigin"
)

// localApiHost is what a real client sends; httptest defaults to example.com,
Expand Down Expand Up @@ -76,6 +81,105 @@ func TestRouter_MetadataHeader(t *testing.T) {
})
}

func TestRouter_ChallengeCarriesOrigin(t *testing.T) {
// The pairing dialog names the caller. app_name comes from the body and is
// the caller's to choose, so the Origin header is the only attributable
// part of the request and has to reach the challenge.
tests := []struct {
name string
headers map[string]string
want string
}{
{
name: "browser origin reaches the challenge",
headers: map[string]string{"Origin": "http://localhost:3000"},
want: "http://localhost:3000",
},
{
name: "origin is passed through verbatim, not normalized",
headers: map[string]string{"Origin": "HTTP://LocalHost:3000"},
want: "HTTP://LocalHost:3000",
},
{
name: "native client without an origin carries none",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// given
fx := newFixture(t)
engine := fx.NewRouter(fx.mwMock, fx.eventMock, []byte{}, []byte{})

var gotOrigin string
fx.mwMock.On("AccountLocalLinkNewChallenge", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
gotOrigin = localorigin.OriginFromContext(args.Get(0).(context.Context))
}).
Return(&pb.RpcAccountLocalLinkNewChallengeResponse{
ChallengeId: "challengeId",
Error: &pb.RpcAccountLocalLinkNewChallengeResponseError{Code: pb.RpcAccountLocalLinkNewChallengeResponseError_NULL},
}).Once()

w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/auth/challenges", strings.NewReader(`{"app_name":"Save to Anytype"}`))
req.Host = localApiHost
req.Header.Set("Content-Type", "application/json")
for k, v := range tt.headers {
req.Header.Set(k, v)
}

// when
engine.ServeHTTP(w, req)

// then
require.Equal(t, http.StatusCreated, w.Code)
require.Equal(t, tt.want, gotOrigin)
})
}
}

func TestEnsureClientProcess(t *testing.T) {
// Resolution is best effort: the pairing dialog still has the origin and
// the app name, so a caller we cannot identify must not be turned away.
tests := []struct {
name string
remoteAddr string
}{
{name: "peer that is not on this machine", remoteAddr: "192.0.2.1:1234"},
{name: "malformed remote address", remoteAddr: "not-an-address"},
{name: "loopback peer with no matching connection", remoteAddr: "127.0.0.1:1"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// given
gin.SetMode(gin.TestMode)
engine := gin.New()
var served bool
var info *grpcprocess.ProcessInfo
engine.POST("/probe", ensureClientProcess(), func(c *gin.Context) {
served = true
info, _ = grpcprocess.FromContext(c.Request.Context())
c.Status(http.StatusOK)
})

w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/probe", nil)
req.RemoteAddr = tt.remoteAddr

// when
engine.ServeHTTP(w, req)

// then
require.True(t, served, "an unidentifiable caller must still reach the handler")
require.Equal(t, http.StatusOK, w.Code)
require.Nil(t, info)
})
}
}

func TestRouter_TrustedOrigin(t *testing.T) {
// The API answers with no CORS headers, so a site cannot read a response.
// It can still reach a handler with a preflight-free "simple" request, and
Expand Down
Loading
Loading