detached f305b1c

fix(rpc): trust a context principal only from the in-process transport

A principal found in the context of a network request, such as one put
there by a server base context, no longer authenticates it: the
interceptor trusts it only when the in-process transport marked the
context with an unexported key. The interceptor also records the client
address for the limits of Watch.
commit
f305b1cd47544e577280a600d69630486db76c94
author
brice · · 2026-10-10 11:10 UTC
parent
1e62968
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +71 -9
M cmd/ermite/api_test.go +33 -0
@@ -8,7 +8,9 @@ import (
88 "crypto/rand"
99 "fmt"
1010 "io"
11+ "net"
1112 "net/http"
13+ "net/http/httptest"
1214 "path/filepath"
1315 "strings"
1416 "testing"
@@ -828,3 +830,34 @@ func TestInProcessClient(t *testing.T) {
828830 _ = resp.Body.Close()
829831 assert.NotContains(t, string(body), `"priv"`)
830832 }
833+
834+// A principal in the base context of the HTTP server does not authenticate
835+// requests: only the in-process transport can mark a context as trusted, and
836+// requests from the network are judged by their headers.
837+func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) {
838+ h := newHarness(t, harnessOpts{})
839+ h.seed("pub", domain.VisibilityPublic, "README.md")
840+ h.seed("priv", domain.VisibilityPrivate, "README.md")
841+
842+ srv := httptest.NewUnstartedServer(h.forge.API.Handler())
843+ srv.Config.BaseContext = func(net.Listener) context.Context {
844+ return domain.ContextWithPrincipal(h.ctx, domain.Root())
845+ }
846+ srv.Start()
847+ t.Cleanup(srv.Close)
848+
849+ list := func(secret string) []string {
850+ c := newClients(srv.URL, secret)
851+ resp, err := c.repo.ListRepos(h.ctx, connect.NewRequest(&pb.ListReposRequest{}))
852+ require.NoError(t, err)
853+ var out []string
854+ for _, r := range resp.Msg.GetRepos() {
855+ out = append(out, r.GetName())
856+ }
857+ return out
858+ }
859+ assert.Equal(t, []string{"pub"}, list(""), "anonymous by its headers, not root by its context")
860+ assert.Equal(t, []string{"priv", "pub"}, list(adminToken))
861+ _, err := newClients(srv.URL, "").repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "nope"}))
862+ assert.Equal(t, "unauthenticated", codeName(err))
863+}
@@ -8,7 +8,9 @@ import (
8 "crypto/rand"8 "crypto/rand"
9 "fmt"9 "fmt"
10 "io"10 "io"
11+ "net"
11 "net/http"12 "net/http"
13+ "net/http/httptest"
12 "path/filepath"14 "path/filepath"
13 "strings"15 "strings"
14 "testing"16 "testing"
@@ -828,3 +830,34 @@ func TestInProcessClient(t *testing.T) {
828 _ = resp.Body.Close()830 _ = resp.Body.Close()
829 assert.NotContains(t, string(body), `"priv"`)831 assert.NotContains(t, string(body), `"priv"`)
830 }832 }
833+
834+// A principal in the base context of the HTTP server does not authenticate
835+// requests: only the in-process transport can mark a context as trusted, and
836+// requests from the network are judged by their headers.
837+func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) {
838+ h := newHarness(t, harnessOpts{})
839+ h.seed("pub", domain.VisibilityPublic, "README.md")
840+ h.seed("priv", domain.VisibilityPrivate, "README.md")
841+
842+ srv := httptest.NewUnstartedServer(h.forge.API.Handler())
843+ srv.Config.BaseContext = func(net.Listener) context.Context {
844+ return domain.ContextWithPrincipal(h.ctx, domain.Root())
845+ }
846+ srv.Start()
847+ t.Cleanup(srv.Close)
848+
849+ list := func(secret string) []string {
850+ c := newClients(srv.URL, secret)
851+ resp, err := c.repo.ListRepos(h.ctx, connect.NewRequest(&pb.ListReposRequest{}))
852+ require.NoError(t, err)
853+ var out []string
854+ for _, r := range resp.Msg.GetRepos() {
855+ out = append(out, r.GetName())
856+ }
857+ return out
858+ }
859+ assert.Equal(t, []string{"pub"}, list(""), "anonymous by its headers, not root by its context")
860+ assert.Equal(t, []string{"priv", "pub"}, list(adminToken))
861+ _, err := newClients(srv.URL, "").repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "nope"}))
862+ assert.Equal(t, "unauthenticated", codeName(err))
863+}
M internal/adapters/rpc/inprocess.go +13 -2
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "context"
67 "io"
78 "net/http"
89 "sync"
@@ -29,7 +30,7 @@ const InProcessURL = "http://" + inProcessHost
2930 // (domain.ContextWithPrincipal), anonymous when there is none; the
3031 // Authorization header is ignored. This is the identity of the web (P5):
3132 // anonymous for visitors, the owner for a session. Only code in this process
32-// can put a principal in a context, which is why the interceptor trusts it.
33+// can put the marker that makes the interceptor trust it.
3334 // Unary and server-streaming calls work, with all the codecs; the request
3435 // log and the error mapping are those of remote calls.
3536 func (a *API) InProcessClient() *http.Client {
@@ -38,6 +39,16 @@ func (a *API) InProcessClient() *http.Client {
3839
3940 type inProcessTransport struct{ h http.Handler }
4041
42+// inProcessKey marks the context of a call made through InProcessClient. It is
43+// unexported, so that nothing outside this package, and nothing that comes
44+// from the network, can make the interceptor trust a principal.
45+type inProcessKey struct{}
46+
47+func inProcess(ctx context.Context) bool {
48+ _, ok := ctx.Value(inProcessKey{}).(struct{})
49+ return ok
50+}
51+
4152 // RoundTrip serves req with the handler in a goroutine, and returns once the
4253 // handler wrote its status. The body of the response is a pipe the handler
4354 // writes to, so that streams are delivered as they are sent.
@@ -47,7 +58,7 @@ func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error)
4758 if !ok {
4859 p = domain.Anonymous()
4960 }
50- sreq := req.Clone(domain.ContextWithPrincipal(ctx, p))
61+ sreq := req.Clone(context.WithValue(domain.ContextWithPrincipal(ctx, p), inProcessKey{}, struct{}{}))
5162 sreq.RemoteAddr = "in-process"
5263 sreq.RequestURI = req.URL.RequestURI()
5364 if sreq.Body == nil {
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "context"
6 "io"7 "io"
7 "net/http"8 "net/http"
8 "sync"9 "sync"
@@ -29,7 +30,7 @@ const InProcessURL = "http://" + inProcessHost
29 // (domain.ContextWithPrincipal), anonymous when there is none; the30 // (domain.ContextWithPrincipal), anonymous when there is none; the
30 // Authorization header is ignored. This is the identity of the web (P5):31 // Authorization header is ignored. This is the identity of the web (P5):
31 // anonymous for visitors, the owner for a session. Only code in this process32 // anonymous for visitors, the owner for a session. Only code in this process
32-// can put a principal in a context, which is why the interceptor trusts it.33+// can put the marker that makes the interceptor trust it.
33 // Unary and server-streaming calls work, with all the codecs; the request34 // Unary and server-streaming calls work, with all the codecs; the request
34 // log and the error mapping are those of remote calls.35 // log and the error mapping are those of remote calls.
35 func (a *API) InProcessClient() *http.Client {36 func (a *API) InProcessClient() *http.Client {
@@ -38,6 +39,16 @@ func (a *API) InProcessClient() *http.Client {
38 39
39 type inProcessTransport struct{ h http.Handler }40 type inProcessTransport struct{ h http.Handler }
40 41
42+// inProcessKey marks the context of a call made through InProcessClient. It is
43+// unexported, so that nothing outside this package, and nothing that comes
44+// from the network, can make the interceptor trust a principal.
45+type inProcessKey struct{}
46+
47+func inProcess(ctx context.Context) bool {
48+ _, ok := ctx.Value(inProcessKey{}).(struct{})
49+ return ok
50+}
51+
41 // RoundTrip serves req with the handler in a goroutine, and returns once the52 // RoundTrip serves req with the handler in a goroutine, and returns once the
42 // handler wrote its status. The body of the response is a pipe the handler53 // handler wrote its status. The body of the response is a pipe the handler
43 // writes to, so that streams are delivered as they are sent.54 // writes to, so that streams are delivered as they are sent.
@@ -47,7 +58,7 @@ func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error)
47 if !ok {58 if !ok {
48 p = domain.Anonymous()59 p = domain.Anonymous()
49 }60 }
50- sreq := req.Clone(domain.ContextWithPrincipal(ctx, p))61+ sreq := req.Clone(context.WithValue(domain.ContextWithPrincipal(ctx, p), inProcessKey{}, struct{}{}))
51 sreq.RemoteAddr = "in-process"62 sreq.RemoteAddr = "in-process"
52 sreq.RequestURI = req.URL.RequestURI()63 sreq.RequestURI = req.URL.RequestURI()
53 if sreq.Body == nil {64 if sreq.Body == nil {
M internal/adapters/rpc/interceptor.go +25 -7
@@ -30,9 +30,12 @@ var errInternal = errors.New("internal error")
3030 // limiter; a client that used up its failures gets resource_exhausted (429 on
3131 // the Connect protocol) whatever it presents, like on the Git routes.
3232 //
33-// A context that already carries a principal (domain.ContextWithPrincipal) is
34-// trusted as it is: that is how the web calls the handlers in process. Such a
35-// context cannot come from the network.
33+// A call made through InProcessClient is trusted with the principal its
34+// context carries: that is how the web calls the handlers in process. What
35+// marks it is a value only the in-process transport can put in the context
36+// (see inProcessKey); a principal in the context of a network request, say one
37+// put there by a server's base context, is ignored and the request is
38+// authenticated from its headers.
3639 type interceptor struct {
3740 auth Authenticator
3841 limiter *clientlimit.Limiter
@@ -45,16 +48,22 @@ func newInterceptor(cfg Config) *interceptor {
4548
4649 // authenticate returns the context to run the call in.
4750 func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
48- if _, ok := domain.PrincipalFromContext(ctx); ok {
49- return ctx, nil
51+ if inProcess(ctx) {
52+ if _, ok := domain.PrincipalFromContext(ctx); ok {
53+ return ctx, nil
54+ }
5055 }
56+ // Whatever the context carried, the caller is anonymous until proven
57+ // otherwise.
58+ ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
5159 if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
5260 // It needs no authentication, so that a client can check compatibility
5361 // first, even with a token that has expired.
54- return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
62+ return ctx, nil
5563 }
5664 log := zerowrap.FromCtx(ctx)
5765 addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
66+ ctx = context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
5867 if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
5968 log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
6069 err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
@@ -64,7 +73,7 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
6473
6574 secret, present := bearer(h)
6675 if !present {
67- return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil
76+ return ctx, nil
6877 }
6978 p, err := i.auth.Authenticate(ctx, secret)
7079 switch {
@@ -78,6 +87,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
7887 return domain.ContextWithPrincipal(ctx, p), nil
7988 }
8089
90+type clientKey struct{}
91+
92+// clientFromContext returns the normalized address of the client of a network
93+// call; empty for an in-process call.
94+func clientFromContext(ctx context.Context) string {
95+ c, _ := ctx.Value(clientKey{}).(string)
96+ return c
97+}
98+
8199 // bearer extracts the secret of an "Authorization: Bearer" header. present is
82100 // true when the header exists, even when it carries nothing usable, which
83101 // then fails authentication.
@@ -30,9 +30,12 @@ var errInternal = errors.New("internal error")
30 // limiter; a client that used up its failures gets resource_exhausted (429 on30 // limiter; a client that used up its failures gets resource_exhausted (429 on
31 // the Connect protocol) whatever it presents, like on the Git routes.31 // the Connect protocol) whatever it presents, like on the Git routes.
32 //32 //
33-// A context that already carries a principal (domain.ContextWithPrincipal) is33+// A call made through InProcessClient is trusted with the principal its
34-// trusted as it is: that is how the web calls the handlers in process. Such a34+// context carries: that is how the web calls the handlers in process. What
35-// context cannot come from the network.35+// marks it is a value only the in-process transport can put in the context
36+// (see inProcessKey); a principal in the context of a network request, say one
37+// put there by a server's base context, is ignored and the request is
38+// authenticated from its headers.
36 type interceptor struct {39 type interceptor struct {
37 auth Authenticator40 auth Authenticator
38 limiter *clientlimit.Limiter41 limiter *clientlimit.Limiter
@@ -45,16 +48,22 @@ func newInterceptor(cfg Config) *interceptor {
45 48
46 // authenticate returns the context to run the call in.49 // authenticate returns the context to run the call in.
47 func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {50 func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
48- if _, ok := domain.PrincipalFromContext(ctx); ok {51+ if inProcess(ctx) {
49- return ctx, nil52+ if _, ok := domain.PrincipalFromContext(ctx); ok {
53+ return ctx, nil
54+ }
50 }55 }
56+ // Whatever the context carried, the caller is anonymous until proven
57+ // otherwise.
58+ ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
51 if procedure == ermiteconnect.ServerServiceGetVersionProcedure {59 if procedure == ermiteconnect.ServerServiceGetVersionProcedure {
52 // It needs no authentication, so that a client can check compatibility60 // It needs no authentication, so that a client can check compatibility
53 // first, even with a token that has expired.61 // first, even with a token that has expired.
54- return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil62+ return ctx, nil
55 }63 }
56 log := zerowrap.FromCtx(ctx)64 log := zerowrap.FromCtx(ctx)
57 addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)65 addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
66+ ctx = context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
58 if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {67 if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
59 log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")68 log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
60 err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))69 err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
@@ -64,7 +73,7 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
64 73
65 secret, present := bearer(h)74 secret, present := bearer(h)
66 if !present {75 if !present {
67- return domain.ContextWithPrincipal(ctx, domain.Anonymous()), nil76+ return ctx, nil
68 }77 }
69 p, err := i.auth.Authenticate(ctx, secret)78 p, err := i.auth.Authenticate(ctx, secret)
70 switch {79 switch {
@@ -78,6 +87,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
78 return domain.ContextWithPrincipal(ctx, p), nil87 return domain.ContextWithPrincipal(ctx, p), nil
79 }88 }
80 89
90+type clientKey struct{}
91+
92+// clientFromContext returns the normalized address of the client of a network
93+// call; empty for an in-process call.
94+func clientFromContext(ctx context.Context) string {
95+ c, _ := ctx.Value(clientKey{}).(string)
96+ return c
97+}
98+
81 // bearer extracts the secret of an "Authorization: Bearer" header. present is99 // bearer extracts the secret of an "Authorization: Bearer" header. present is
82 // true when the header exists, even when it carries nothing usable, which100 // true when the header exists, even when it carries nothing usable, which
83 // then fails authentication.101 // then fails authentication.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close