Bind prompt history to Gitea's server-rendered session identity without an API token

Hop-State: A_06FNB7D05ZQ3AHD1V0GY5P8
Hop-Proposal: R_06FNB7CDK6ZKM505CPR2DY8
Hop-Task: T_06FNB715NCG0M3SVDJAGHY8
Hop-Attempt: AT_06FNB715NCK5MKAVH59TE0G
This commit is contained in:
2026-07-12 02:17:01 -07:00
committed by Hop
parent 96cd6823b2
commit 6be764ec6e
4 changed files with 29 additions and 31 deletions
@@ -1,3 +1,7 @@
{{if .IsSigned}}
<meta name="hop-signed-user-id" content="{{.SignedUser.ID}}">
<meta name="hop-signed-user-login" content="{{.SignedUser.Name}}">
{{end}}
<link rel="preload" href="{{AssetUrlPrefix}}/img/hop.svg?v=4" as="image" type="image/svg+xml"> <link rel="preload" href="{{AssetUrlPrefix}}/img/hop.svg?v=4" as="image" type="image/svg+xml">
<style> <style>
html:not([data-hop-native="true"]) { html:not([data-hop-native="true"]) {
+3 -3
View File
@@ -33,9 +33,9 @@ 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 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 settings route. Hop's custom Gitea header renders the active account's immutable
to Gitea's immutable user ID. Gitea's `/api/v1/user` endpoint is token-only and user ID into that protected response, so browser-session verification does not
must not be used to validate browser sessions. depend on Gitea's token-only `/api/v1/user` endpoint or `GITEA_API_TOKEN`.
For nginx, the control-plane location should precede the Gitea catch-all: For nginx, the control-plane location should precede the Gitea catch-all:
+15 -14
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -17,8 +18,8 @@ import (
var ErrTokenNotConfigured = errors.New("gitea API token is not configured") var ErrTokenNotConfigured = errors.New("gitea API token is not configured")
var ( var (
settingsUsernameInput = regexp.MustCompile(`(?is)<input\b[^>]*\bid\s*=\s*["']username["'][^>]*>`) signedUserIDMeta = regexp.MustCompile(`(?is)<meta\s+name=["']hop-signed-user-id["']\s+content=["']([0-9]+)["']\s*/?>`)
settingsDataName = regexp.MustCompile(`(?is)\bdata-name\s*=\s*(?:"([^"]*)"|'([^']*)')`) signedUserLoginMeta = regexp.MustCompile(`(?is)<meta\s+name=["']hop-signed-user-login["']\s+content=(?:"([^"]*)"|'([^']*)')\s*/?>`)
) )
type Repository struct { type Repository struct {
@@ -135,8 +136,8 @@ func (c *Client) AuthenticatedUser(ctx context.Context, cookie string) (User, bo
return User{}, false, nil return User{}, false, nil
} }
// Gitea's API user endpoint requires an API token and intentionally rejects // Gitea's API user endpoint requires an API token and intentionally rejects
// browser sessions. The profile settings page is session-authenticated and // browser sessions. The protected profile settings page includes Hop's custom
// renders the signed-in username in a stable input contract. // server-rendered user identity metadata for the active Gitea session.
u := c.baseURL.JoinPath("user", "settings") 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 {
@@ -163,24 +164,24 @@ func (c *Client) AuthenticatedUser(ctx context.Context, cookie string) (User, bo
if err != nil { if err != nil {
return User{}, false, fmt.Errorf("read authenticated user settings: %w", err) return User{}, false, fmt.Errorf("read authenticated user settings: %w", err)
} }
input := settingsUsernameInput.Find(body) idMatch := signedUserIDMeta.FindSubmatch(body)
attribute := settingsDataName.FindSubmatch(input) loginMatch := signedUserLoginMeta.FindSubmatch(body)
if len(attribute) != 3 { if len(idMatch) != 2 || len(loginMatch) != 3 {
return User{}, false, errors.New("gitea settings page did not identify the signed-in user") return User{}, false, errors.New("gitea settings page did not identify the signed-in user")
} }
login := string(attribute[1]) id, err := strconv.ParseInt(string(idMatch[1]), 10, 64)
if err != nil || id <= 0 {
return User{}, false, errors.New("gitea settings page returned an invalid user ID")
}
login := string(loginMatch[1])
if login == "" { if login == "" {
login = string(attribute[2]) login = string(loginMatch[2])
} }
login = strings.TrimSpace(html.UnescapeString(login)) login = strings.TrimSpace(html.UnescapeString(login))
if login == "" { if login == "" {
return User{}, false, errors.New("gitea settings page returned an empty username") return User{}, false, errors.New("gitea settings page returned an empty username")
} }
user, err := c.User(ctx, login) return User{ID: id, Login: login}, true, nil
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 against // ViewerCanReadRepository checks a browser's existing Gitea session against
@@ -66,28 +66,21 @@ 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) {
switch r.URL.Path { if r.URL.Path != "/user/settings" {
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) t.Fatalf("path = %q", r.URL.Path)
} }
if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {
t.Fatalf("cookie = %q", got)
}
_, _ = w.Write([]byte(`<meta name="hop-signed-user-id" content="42"><meta name="hop-signed-user-login" content="alice">`))
})) }))
defer server.Close() defer server.Close()
client, err := NewClient(server.URL, "gitea-token") client, err := NewClient(server.URL, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
user, authenticated, err := client.AuthenticatedUser(context.Background(), "i_like_gitea=session") user, authenticated, err := client.AuthenticatedUser(context.Background(), "i_like_gitea=session")
if err != nil || !authenticated || user.ID != 42 { if err != nil || !authenticated || user.ID != 42 || user.LoginName() != "alice" {
t.Fatalf("user = %#v, authenticated = %t, err = %v", user, authenticated, err) t.Fatalf("user = %#v, authenticated = %t, err = %v", user, authenticated, err)
} }
} }