Skip to content

Commit 6e53722

Browse files
committed
feat(contributor): implement header forwarding and proxying for remote page requests
1 parent 405d586 commit 6e53722

6 files changed

Lines changed: 317 additions & 7 deletions

File tree

extensions/dashboard/contributor/remote.go

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,49 @@ type RemoteContributor struct {
2020
lastFetch time.Time
2121
}
2222

23+
// forwardedHeadersKey is the context key the proxy uses to pass through
24+
// inbound-request headers (typically `Authorization` and `Cookie`) on
25+
// the server-to-server call to a remote contributor. Without this, the
26+
// remote sees an unauthenticated S2S request and cannot identify the
27+
// end user — that breaks any contributor handler that needs the
28+
// caller's identity (e.g. authsome's dashboard "Create API Key" form,
29+
// which binds the new key to the dashboard user's UserID).
30+
type forwardedHeadersKey struct{}
31+
32+
// WithForwardedHeaders returns a derived context that carries the
33+
// supplied headers; RemoteContributor.send forwards a fixed allowlist
34+
// of them (Authorization, Cookie) on the outbound HTTP request. Pass
35+
// the inbound *http.Request.Header verbatim — only the allowlisted
36+
// keys are forwarded, so unrelated headers (Host, Content-Length, …)
37+
// are not leaked.
38+
//
39+
// Trust model: the host explicitly registers a remote contributor via
40+
// WatchRemoteContributor / AddRemoteContributor, so forwarding the
41+
// end user's auth to that registered URL is in-scope of an existing
42+
// trust boundary. Do NOT forward to arbitrary URLs.
43+
func WithForwardedHeaders(ctx context.Context, h http.Header) context.Context {
44+
if h == nil {
45+
return ctx
46+
}
47+
48+
return context.WithValue(ctx, forwardedHeadersKey{}, h.Clone())
49+
}
50+
51+
// forwardedHeadersFrom returns the forwarded headers attached to ctx,
52+
// or nil if none were set.
53+
func forwardedHeadersFrom(ctx context.Context) http.Header {
54+
v, _ := ctx.Value(forwardedHeadersKey{}).(http.Header) //nolint:errcheck // type-safe via key
55+
56+
return v
57+
}
58+
59+
// forwardableHeaderNames lists the inbound headers RemoteContributor
60+
// will forward on S2S calls. Authorization carries Bearer tokens (JWT
61+
// or session), Cookie carries the host-side session cookie when the
62+
// dashboard uses cookie auth. Anything else is dropped to avoid
63+
// leaking unrelated request metadata to the remote.
64+
var forwardableHeaderNames = []string{"Authorization", "Cookie"}
65+
2366
// RemoteContributorOption configures a RemoteContributor.
2467
type RemoteContributorOption func(*RemoteContributor)
2568

@@ -77,6 +120,20 @@ func (rc *RemoteContributor) FetchPage(ctx context.Context, route, rawQuery stri
77120
return rc.fetch(ctx, url)
78121
}
79122

123+
// PostPage forwards a POST to the remote contributor's page endpoint.
124+
// Used to proxy form submissions from the host dashboard. The remote handler
125+
// re-renders the page with FormData populated from the parsed body. body and
126+
// contentType come straight from the inbound request — typically
127+
// application/x-www-form-urlencoded or multipart/form-data.
128+
func (rc *RemoteContributor) PostPage(ctx context.Context, route, rawQuery string, body io.Reader, contentType string) ([]byte, error) {
129+
url := rc.baseURL + "/_forge/dashboard/pages" + route
130+
if rawQuery != "" {
131+
url += "?" + rawQuery
132+
}
133+
134+
return rc.send(ctx, http.MethodPost, url, body, contentType)
135+
}
136+
80137
// FetchWidget fetches an HTML widget fragment from the remote service.
81138
func (rc *RemoteContributor) FetchWidget(ctx context.Context, widgetID string) ([]byte, error) {
82139
url := rc.baseURL + "/_forge/dashboard/widgets/" + widgetID
@@ -91,9 +148,15 @@ func (rc *RemoteContributor) FetchSettings(ctx context.Context, settingID string
91148
return rc.fetch(ctx, url)
92149
}
93150

94-
// fetch makes an authenticated HTTP request and returns the response body.
151+
// fetch makes an authenticated GET request and returns the response body.
95152
func (rc *RemoteContributor) fetch(ctx context.Context, url string) ([]byte, error) {
96-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
153+
return rc.send(ctx, http.MethodGet, url, nil, "")
154+
}
155+
156+
// send executes an authenticated request against the remote contributor's
157+
// protocol with the given method/body. Returns the response body bytes.
158+
func (rc *RemoteContributor) send(ctx context.Context, method, url string, body io.Reader, contentType string) ([]byte, error) {
159+
req, err := http.NewRequestWithContext(ctx, method, url, body)
97160
if err != nil {
98161
return nil, fmt.Errorf("failed to create request: %w", err)
99162
}
@@ -102,10 +165,28 @@ func (rc *RemoteContributor) fetch(ctx context.Context, url string) ([]byte, err
102165
req.Header.Set("X-Forge-Dashboard", "true")
103166
req.Header.Set("Accept", "text/html")
104167

168+
if contentType != "" {
169+
req.Header.Set("Content-Type", contentType)
170+
}
171+
105172
if rc.apiKey != "" {
106173
req.Header.Set("X-Forge-Api-Key", rc.apiKey)
107174
}
108175

176+
// Forward the allowlisted inbound headers (e.g. Authorization,
177+
// Cookie) when the host has populated them via
178+
// WithForwardedHeaders. The remote contributor's auth middleware
179+
// reads these to resolve the end user — without it, S2S calls
180+
// from the host arrive unauthenticated and any handler that
181+
// needs a UserID has no choice but to fail or fabricate one.
182+
if fwd := forwardedHeadersFrom(ctx); fwd != nil {
183+
for _, name := range forwardableHeaderNames {
184+
if v := fwd.Get(name); v != "" {
185+
req.Header.Set(name, v)
186+
}
187+
}
188+
}
189+
109190
resp, err := rc.client.Do(req)
110191
if err != nil {
111192
return nil, fmt.Errorf("remote request failed: %w", err)
@@ -116,14 +197,14 @@ func (rc *RemoteContributor) fetch(ctx context.Context, url string) ([]byte, err
116197
return nil, fmt.Errorf("remote returned status %d for %s", resp.StatusCode, url)
117198
}
118199

119-
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) // 5MB limit
200+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) // 5MB limit
120201
if err != nil {
121202
return nil, fmt.Errorf("failed to read response body: %w", err)
122203
}
123204

124205
rc.lastFetch = time.Now()
125206

126-
return body, nil
207+
return respBody, nil
127208
}
128209

129210
// FetchManifest fetches the dashboard manifest from a remote service URL.

extensions/dashboard/contributor/remote_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package contributor
33
import (
44
"context"
55
"encoding/json"
6+
"io"
67
"net/http"
78
"net/http/httptest"
89
"os"
910
"path/filepath"
11+
"strings"
1012
"testing"
1113
"time"
1214
)
@@ -105,6 +107,56 @@ func TestRemoteContributor_FetchPage_PropagatesAPIKey(t *testing.T) {
105107
}
106108
}
107109

110+
// TestRemoteContributor_ForwardsAuthAndCookie pins the user-identity
111+
// forwarding contract: when the host attaches inbound headers via
112+
// WithForwardedHeaders, the S2S request to the remote MUST carry the
113+
// allowlisted Authorization and Cookie headers and MUST NOT carry
114+
// other inbound headers (Host metadata, custom internal markers,
115+
// etc.) that would leak unrelated request context.
116+
func TestRemoteContributor_ForwardsAuthAndCookie(t *testing.T) {
117+
rs := newRecorderServer(t)
118+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
119+
120+
inbound := http.Header{}
121+
inbound.Set("Authorization", "Bearer user-tok-abc")
122+
inbound.Set("Cookie", "authsome_session_token=cookie-val")
123+
inbound.Set("X-Internal-Trace", "should-not-be-forwarded")
124+
125+
ctx := WithForwardedHeaders(context.Background(), inbound)
126+
127+
if _, err := rc.FetchPage(ctx, "/users", ""); err != nil {
128+
t.Fatalf("FetchPage: %v", err)
129+
}
130+
131+
if got, want := rs.lastHeaders.Get("Authorization"), "Bearer user-tok-abc"; got != want {
132+
t.Fatalf("Authorization = %q, want %q", got, want)
133+
}
134+
if got, want := rs.lastHeaders.Get("Cookie"), "authsome_session_token=cookie-val"; got != want {
135+
t.Fatalf("Cookie = %q, want %q", got, want)
136+
}
137+
if got := rs.lastHeaders.Get("X-Internal-Trace"); got != "" {
138+
t.Fatalf("X-Internal-Trace must not be forwarded; got %q", got)
139+
}
140+
}
141+
142+
// TestRemoteContributor_NoForwardedHeaders pins backward compat: with
143+
// no WithForwardedHeaders on the context, the outbound request must
144+
// not carry an Authorization header (which would otherwise leak from
145+
// some unrelated source). This is the historical behaviour every
146+
// existing caller relies on.
147+
func TestRemoteContributor_NoForwardedHeaders(t *testing.T) {
148+
rs := newRecorderServer(t)
149+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
150+
151+
if _, err := rc.FetchPage(context.Background(), "/users", ""); err != nil {
152+
t.Fatalf("FetchPage: %v", err)
153+
}
154+
155+
if got := rs.lastHeaders.Get("Authorization"); got != "" {
156+
t.Fatalf("Authorization must be empty without WithForwardedHeaders; got %q", got)
157+
}
158+
}
159+
108160
func TestRemoteContributor_FetchPage_ErrorOnNon2xx(t *testing.T) {
109161
rs := newRecorderServer(t)
110162
rs.respond = func(w http.ResponseWriter, _ *http.Request) {
@@ -119,6 +171,53 @@ func TestRemoteContributor_FetchPage_ErrorOnNon2xx(t *testing.T) {
119171
}
120172
}
121173

174+
func TestRemoteContributor_PostPage_ForwardsBodyAndContentType(t *testing.T) {
175+
rs := newRecorderServer(t)
176+
177+
var seenBody []byte
178+
var seenMethod, seenContentType string
179+
180+
rs.respond = func(w http.ResponseWriter, r *http.Request) {
181+
seenMethod = r.Method
182+
seenContentType = r.Header.Get("Content-Type")
183+
seenBody, _ = io.ReadAll(r.Body)
184+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
185+
_, _ = w.Write([]byte("<div>posted</div>"))
186+
}
187+
188+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
189+
190+
body := strings.NewReader("name=alice&role=admin")
191+
got, err := rc.PostPage(context.Background(), "/users/create", "id=ausr_1", body, "application/x-www-form-urlencoded")
192+
if err != nil {
193+
t.Fatalf("PostPage: %v", err)
194+
}
195+
196+
if string(got) != "<div>posted</div>" {
197+
t.Fatalf("response = %q, want <div>posted</div>", got)
198+
}
199+
200+
if seenMethod != http.MethodPost {
201+
t.Errorf("upstream method = %q, want POST", seenMethod)
202+
}
203+
204+
if got, want := rs.lastPath, "/_forge/dashboard/pages/users/create"; got != want {
205+
t.Errorf("path = %q, want %q", got, want)
206+
}
207+
208+
if got, want := rs.lastQuery, "id=ausr_1"; got != want {
209+
t.Errorf("query = %q, want %q", got, want)
210+
}
211+
212+
if seenContentType != "application/x-www-form-urlencoded" {
213+
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", seenContentType)
214+
}
215+
216+
if got, want := string(seenBody), "name=alice&role=admin"; got != want {
217+
t.Errorf("body = %q, want %q", got, want)
218+
}
219+
}
220+
122221
func TestRemoteContributor_FetchPage_ContextCancelled(t *testing.T) {
123222
rs := newRecorderServer(t)
124223
rs.respond = func(w http.ResponseWriter, r *http.Request) {

extensions/dashboard/discovery/integration.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ func (i *Integration) refreshRemoteService(ctx context.Context, inst *ServiceIns
362362
)
363363
}
364364

365-
366365
// TrackedCount returns the number of tracked remote contributors.
367366
func (i *Integration) TrackedCount() int {
368367
i.mu.Lock()

extensions/dashboard/pages/pages.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func (pm *PagesManager) remoteExtensionHandler(contribName string) router.PageHa
263263
pageBase := fmt.Sprintf("%s/remote/%s/pages", pm.basePath, contribName)
264264
fetchQuery := buildProxyFetchQuery(ctx.Request.URL.RawQuery, pm.basePath, pageBase)
265265

266-
data, err := pm.fragmentProxy.FetchPage(ctx.Context(), contribName, route, fetchQuery)
266+
data, err := proxyToRemote(ctx, pm.fragmentProxy, contribName, route, fetchQuery)
267267
if err != nil {
268268
return uipages.ErrorPage(502, "Remote Unavailable", //nolint:nilerr // surface as page rather than propagating
269269
"Failed to load page from remote extension '"+contribName+"': "+err.Error(),
@@ -274,6 +274,31 @@ func (pm *PagesManager) remoteExtensionHandler(contribName string) router.PageHa
274274
}
275275
}
276276

277+
// proxyToRemote forwards a host page request (GET or POST) to a remote
278+
// contributor via the FragmentProxy. POSTs route through PostPage so the
279+
// inbound body + content-type reach the upstream's form handler; GETs use
280+
// the cached FetchPage.
281+
//
282+
// The inbound request's Authorization and Cookie headers are forwarded
283+
// to the remote via contributor.WithForwardedHeaders so handlers there
284+
// can identify the end user (the registered remote is already a
285+
// trusted target — the host registered it explicitly via
286+
// WatchRemoteContributor / AddRemoteContributor).
287+
func proxyToRemote(ctx *router.PageContext, fp *proxy.FragmentProxy, name, route, query string) ([]byte, error) {
288+
fwdCtx := contributor.WithForwardedHeaders(ctx.Context(), ctx.Request.Header)
289+
290+
if ctx.Request.Method == http.MethodPost {
291+
contentType := ctx.Request.Header.Get("Content-Type")
292+
// We pass the raw body through. The upstream's POST handler is
293+
// responsible for parsing the form (whether url-encoded or
294+
// multipart). Closing of the body is handled by the http.Request
295+
// lifecycle on the host side.
296+
return fp.PostPage(fwdCtx, name, route, query, ctx.Request.Body, contentType)
297+
}
298+
299+
return fp.FetchPage(fwdCtx, name, route, query)
300+
}
301+
277302
// buildProxyFetchQuery merges the user's incoming query string with the
278303
// reserved bp (basePath) and pb (pageBase) parameters that contributor
279304
// protocol handlers consume to render correctly-prefixed links back to the
@@ -712,7 +737,7 @@ func (pm *PagesManager) RemotePage(ctx *router.PageContext) (templ.Component, er
712737
pageBase := fmt.Sprintf("%s/remote/%s/pages", pm.basePath, name)
713738
fetchQuery := buildProxyFetchQuery(ctx.Request.URL.RawQuery, pm.basePath, pageBase)
714739

715-
data, err := pm.fragmentProxy.FetchPage(ctx.Context(), name, route, fetchQuery)
740+
data, err := proxyToRemote(ctx, pm.fragmentProxy, name, route, fetchQuery)
716741
if err != nil {
717742
return uipages.ErrorPage(502, "Remote Unavailable", //nolint:nilerr // render error page instead of propagating
718743
"Failed to load page from remote extension '"+name+"': "+err.Error(),

0 commit comments

Comments
 (0)