Recognize authenticated Gitea browser sessions without weakening per-user prompt isolation

Hop-State: A_06FNB6HTKZAXH2SX1G8AB0R
Hop-Proposal: R_06FNB6HA40Y1E1T39G6YJHG
Hop-Task: T_06FNB5TE4DHAVABVH82WBN8
Hop-Attempt: AT_06FNB5TE4FW7MD6KTFYP3EG
This commit is contained in:
2026-07-12 02:13:18 -07:00
committed by Hop
parent 12c806c77f
commit 96cd6823b2
3 changed files with 104 additions and 25 deletions
+5
View File
@@ -32,6 +32,11 @@ plane, preserving the browser's Gitea session cookie. This lets the Prompts
view check the viewer's repository access before returning potentially view check the viewer's repository access before returning potentially
sensitive prompt text. sensitive prompt text.
The control plane verifies the browser session through Gitea's protected web
settings route, then uses `GITEA_API_TOKEN` to resolve the server-rendered login
to Gitea's immutable user ID. Gitea's `/api/v1/user` endpoint is token-only and
must not be used to validate browser sessions.
For nginx, the control-plane location should precede the Gitea catch-all: For nginx, the control-plane location should precede the Gitea catch-all:
```nginx ```nginx
+55 -18
View File
@@ -5,14 +5,22 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html"
"io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"time" "time"
) )
var ErrTokenNotConfigured = errors.New("gitea API token is not configured") var ErrTokenNotConfigured = errors.New("gitea API token is not configured")
var (
settingsUsernameInput = regexp.MustCompile(`(?is)<input\b[^>]*\bid\s*=\s*["']username["'][^>]*>`)
settingsDataName = regexp.MustCompile(`(?is)\bdata-name\s*=\s*(?:"([^"]*)"|'([^']*)')`)
)
type Repository struct { type Repository struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Owner Owner `json:"owner"` Owner Owner `json:"owner"`
@@ -126,51 +134,71 @@ func (c *Client) AuthenticatedUser(ctx context.Context, cookie string) (User, bo
if strings.TrimSpace(cookie) == "" { if strings.TrimSpace(cookie) == "" {
return User{}, false, nil return User{}, false, nil
} }
u := c.baseURL.JoinPath("api", "v1", "user") // Gitea's API user endpoint requires an API token and intentionally rejects
// browser sessions. The profile settings page is session-authenticated and
// renders the signed-in username in a stable input contract.
u := c.baseURL.JoinPath("user", "settings")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil { if err != nil {
return User{}, false, err return User{}, false, err
} }
req.Header.Set("Cookie", cookie) req.Header.Set("Cookie", cookie)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "text/html")
resp, err := c.http.Do(req) resp, err := c.doWithoutRedirect(req)
if err != nil { if err != nil {
return User{}, false, fmt.Errorf("load authenticated user: %w", err) return User{}, false, fmt.Errorf("load authenticated user: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { switch resp.StatusCode {
case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect,
http.StatusUnauthorized, http.StatusForbidden:
return User{}, false, nil return User{}, false, nil
} case http.StatusOK:
if resp.StatusCode != http.StatusOK { // Continue below.
default:
return User{}, false, fmt.Errorf("gitea authenticated user lookup returned %s", resp.Status) return User{}, false, fmt.Errorf("gitea authenticated user lookup returned %s", resp.Status)
} }
var user User body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { if err != nil {
return User{}, false, fmt.Errorf("decode authenticated user: %w", err) return User{}, false, fmt.Errorf("read authenticated user settings: %w", err)
} }
if user.ID <= 0 || user.LoginName() == "" { input := settingsUsernameInput.Find(body)
return User{}, false, errors.New("gitea returned an incomplete authenticated user") attribute := settingsDataName.FindSubmatch(input)
if len(attribute) != 3 {
return User{}, false, errors.New("gitea settings page did not identify the signed-in user")
}
login := string(attribute[1])
if login == "" {
login = string(attribute[2])
}
login = strings.TrimSpace(html.UnescapeString(login))
if login == "" {
return User{}, false, errors.New("gitea settings page returned an empty username")
}
user, err := c.User(ctx, login)
if err != nil {
return User{}, false, fmt.Errorf("resolve authenticated user %q: %w", login, err)
} }
return user, true, nil return user, true, nil
} }
// ViewerCanReadRepository checks a browser's existing Gitea session before // ViewerCanReadRepository checks a browser's existing Gitea session against
// returning prompt data. Prompts may contain sensitive intent, so the Hop API // the repository web route. Gitea API routes require API tokens and do not
// must never turn a private repository into a public metadata feed. // recognize browser sessions.
func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) { func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) {
if strings.TrimSpace(cookie) == "" { if strings.TrimSpace(cookie) == "" {
return false, nil return false, nil
} }
u := c.baseURL.JoinPath("api", "v1", "repos", owner, name) u := c.baseURL.JoinPath(owner, name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil { if err != nil {
return false, err return false, err
} }
req.Header.Set("Cookie", cookie) req.Header.Set("Cookie", cookie)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "text/html")
resp, err := c.http.Do(req) resp, err := c.doWithoutRedirect(req)
if err != nil { if err != nil {
return false, fmt.Errorf("check viewer repository access: %w", err) return false, fmt.Errorf("check viewer repository access: %w", err)
} }
@@ -178,9 +206,18 @@ func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, nam
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusOK: case http.StatusOK:
return true, nil return true, nil
case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect,
http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
return false, nil return false, nil
default: default:
return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status) return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status)
} }
} }
func (c *Client) doWithoutRedirect(req *http.Request) (*http.Response, error) {
client := *c.http
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return client.Do(req)
}
@@ -66,16 +66,23 @@ func TestUserUsesTokenAndDecodesStableID(t *testing.T) {
func TestAuthenticatedUserForwardsSessionCookie(t *testing.T) { func TestAuthenticatedUserForwardsSessionCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user" { switch r.URL.Path {
t.Fatalf("path = %q", r.URL.Path) case "/user/settings":
}
if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" { if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {
t.Fatalf("cookie = %q", got) t.Fatalf("cookie = %q", got)
} }
_, _ = w.Write([]byte(`<input id="username" name="name" value="alice" data-name="alice" required>`))
case "/api/v1/users/alice":
if got := r.Header.Get("Authorization"); got != "token gitea-token" {
t.Fatalf("authorization = %q", got)
}
_, _ = w.Write([]byte(`{"id":42,"login":"alice"}`)) _, _ = w.Write([]byte(`{"id":42,"login":"alice"}`))
default:
t.Fatalf("path = %q", r.URL.Path)
}
})) }))
defer server.Close() defer server.Close()
client, err := NewClient(server.URL, "") client, err := NewClient(server.URL, "gitea-token")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -85,6 +92,36 @@ func TestAuthenticatedUserForwardsSessionCookie(t *testing.T) {
} }
} }
func TestAuthenticatedUserRejectsLoginRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}))
defer server.Close()
client, err := NewClient(server.URL, "gitea-token")
if err != nil {
t.Fatal(err)
}
user, authenticated, err := client.AuthenticatedUser(context.Background(), "i_like_gitea=expired")
if err != nil || authenticated || user.ID != 0 {
t.Fatalf("user = %#v, authenticated = %t, err = %v", user, authenticated, err)
}
}
func TestAuthenticatedUserRejectsUnidentifiableSettingsPage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`<main>settings changed</main>`))
}))
defer server.Close()
client, err := NewClient(server.URL, "gitea-token")
if err != nil {
t.Fatal(err)
}
_, authenticated, err := client.AuthenticatedUser(context.Background(), "i_like_gitea=session")
if err == nil || authenticated {
t.Fatalf("authenticated = %t, err = %v", authenticated, err)
}
}
func TestAuthenticatedUserRejectsMissingSession(t *testing.T) { func TestAuthenticatedUserRejectsMissingSession(t *testing.T) {
client, err := NewClient("http://gitea.test", "") client, err := NewClient("http://gitea.test", "")
if err != nil { if err != nil {
@@ -98,7 +135,7 @@ func TestAuthenticatedUserRejectsMissingSession(t *testing.T) {
func TestViewerCanReadRepositoryForwardsSessionCookie(t *testing.T) { func TestViewerCanReadRepositoryForwardsSessionCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/hop/private" { if r.URL.Path != "/hop/private" {
t.Fatalf("path = %q", r.URL.Path) t.Fatalf("path = %q", r.URL.Path)
} }
if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" { if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {