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
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:
```nginx
+55 -18
View File
@@ -5,14 +5,22 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
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 {
ID int64 `json:"id"`
Owner Owner `json:"owner"`
@@ -126,51 +134,71 @@ func (c *Client) AuthenticatedUser(ctx context.Context, cookie string) (User, bo
if strings.TrimSpace(cookie) == "" {
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)
if err != nil {
return User{}, false, err
}
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 {
return User{}, false, fmt.Errorf("load authenticated user: %w", err)
}
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
}
if resp.StatusCode != http.StatusOK {
case http.StatusOK:
// Continue below.
default:
return User{}, false, fmt.Errorf("gitea authenticated user lookup returned %s", resp.Status)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return User{}, false, fmt.Errorf("decode authenticated user: %w", err)
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return User{}, false, fmt.Errorf("read authenticated user settings: %w", err)
}
if user.ID <= 0 || user.LoginName() == "" {
return User{}, false, errors.New("gitea returned an incomplete authenticated user")
input := settingsUsernameInput.Find(body)
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
}
// ViewerCanReadRepository checks a browser's existing Gitea session before
// returning prompt data. Prompts may contain sensitive intent, so the Hop API
// must never turn a private repository into a public metadata feed.
// ViewerCanReadRepository checks a browser's existing Gitea session against
// the repository web route. Gitea API routes require API tokens and do not
// recognize browser sessions.
func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) {
if strings.TrimSpace(cookie) == "" {
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)
if err != nil {
return false, err
}
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 {
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 {
case http.StatusOK:
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
default:
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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user" {
t.Fatalf("path = %q", r.URL.Path)
}
switch r.URL.Path {
case "/user/settings":
if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {
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"}`))
default:
t.Fatalf("path = %q", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(server.URL, "")
client, err := NewClient(server.URL, "gitea-token")
if err != nil {
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) {
client, err := NewClient("http://gitea.test", "")
if err != nil {
@@ -98,7 +135,7 @@ func TestAuthenticatedUserRejectsMissingSession(t *testing.T) {
func TestViewerCanReadRepositoryForwardsSessionCookie(t *testing.T) {
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)
}
if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {