Files
verdande/internal/httpapi/app_handlers.go
T
kristianwindandClaude Opus 5.5 a71af41d9e feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
Andet trin mod iOS-appen. Appen henter sin flade fra capacitor://localhost og
taler med den server, ejeren har skrevet ind, så hver anmodning er cross-origin.

- CORS på /api/v1 for appens origin og ingen andres. Ingen
  Allow-Credentials: appen bærer sin session som bearer, aldrig som cookie.
  Alle andre origins får ingen headere, præcis som før.
- POST /ws-ticket giver en engangsbillet, der lever 30 sekunder, og
  /ws?ticket=… åbner socket'en med den. En browser kan ikke sætte en header
  på et WebSocket-håndtryk, og tokenet selv i URL'en ville ligge i hver
  proxy-log i månedsvis. En socket med billet springer origin-tjekket over,
  fordi den ikke bærer nogen cookie, som en fremmed side kunne låne.
- Et håndtryk fra appens origin uden billet afvises. Prøven fandt hvorfor:
  biblioteket sammenligner kun værtsnavne, og capacitor://localhost har samme
  værtsnavn som en instans på http://localhost.
- /ping svarer med product, edition og api, så appen kan kende en Verdande
  fra en routers login-side, før nogen er logget ind. Ingen version: /version
  er bag et login med vilje.

Fem plantede fejl, hver fanget af sin egen prøve: CORS til enhver origin,
genbrugelig billet, billet uden udløb, app-origin på en cookie, og
Allow-Credentials.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:03:01 +02:00

170 lines
5.8 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)
// What the iOS app needs from a server that a browser gets for free by being on
// the same origin. The app loads its interface from capacitor://localhost and
// talks to whichever server its owner typed in, so every request it makes is
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
// has signed in.
// appCORS answers the app's origin, and nobody else's.
//
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
// token, never a cookie, so a response to a request with a cookie on it is not
// something this header ever needs to unlock. Every other origin gets no CORS
// headers at all, exactly as before — the browser then keeps a foreign page from
// reading anything, which is the default this must not loosen.
func appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !isAppOrigin(origin) {
next.ServeHTTP(w, r)
return
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
// Read by the client: a rate limit's wait, and a download's file name.
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// --- the live-sync socket without a cookie -----------------------------------
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
// token has no way onto it. Putting the token in the URL would work and would
// leave a credential that lasts months in every proxy log the URL passes. A
// ticket is the usual answer: asked for with the token, good for one connection,
// gone in thirty seconds.
const wsTicketTTL = 30 * time.Second
type wsTicket struct {
userID string
expires time.Time
}
type wsTickets struct {
mu sync.Mutex
m map[string]wsTicket
}
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
func (t *wsTickets) issue(userID string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
ticket := hex.EncodeToString(raw)
t.mu.Lock()
defer t.mu.Unlock()
// Swept on the way in, so the map is bounded by thirty seconds of asking.
now := time.Now()
for k, v := range t.m {
if now.After(v.expires) {
delete(t.m, k)
}
}
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
return ticket, nil
}
// redeem spends a ticket. It is deleted before it is checked, so a second
// presentation finds nothing, whether the first one was in time or not.
func (t *wsTickets) redeem(ticket string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
v, ok := t.m[ticket]
delete(t.m, ticket)
if !ok || time.Now().After(v.expires) {
return "", false
}
return v.userID, true
}
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "issue websocket ticket", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ticket": ticket,
"expires_in": int(wsTicketTTL.Seconds()),
})
}
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
// ordinary session check.
//
// A socket opened with a ticket skips the origin check, and that is the point
// rather than a gap: the origin check exists because a browser attaches the
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
// be asked for with a credential and put in the URL on purpose — so there is no
// ambient authority for a foreign page to borrow.
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
// The app never holds a cookie, so a handshake from its origin without a
// ticket is not the app. Refused here rather than left to the origin
// check, which compares host names only: capacitor://localhost and an
// instance served at http://localhost have the same one.
if isAppOrigin(r.Header.Get("Origin")) {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
return
}
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
return
}
userID, ok := s.wsTickets.redeem(ticket)
if !ok {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
return
}
user, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, r, "load user", err)
return
}
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
s.handleWebSocket(w, r.WithContext(ctx))
}
// --- is this a Verdande? -----------------------------------------------------
// handlePing answers before anybody has signed in, which is when the app needs
// it: the person has typed an address and the app has to tell "that is a
// Verdande" from "that is a router's login page" before it shows a password
// field for it. The edition decides which half of the interface to show.
//
// No version. /version sits behind a login on purpose, and a public number is a
// list of the instances still running an old one.
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"product": "verdande",
"edition": s.edition(),
"api": 1,
})
}