Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind. Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er præcis, hvad det var før. - `apiURL()` og `authorize()`: relative stier og cookie i en browser; den valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`. - Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra telefonen, også når serveren ikke svarede. - Live-synk i appen beder om en billet og åbner socket'en på serveren med den. - Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af første konto vises ikke i appen; begge hører til serverens eget domæne. - Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob, i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse. - Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem. Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en, viser et billede fra en blob og logger ud. Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer, socket uden billet, billeder ikke hentet, token efterladt ved log ud. Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1. Co-Authored-By: Claude Opus 5.5 <[email protected]>
225 lines
7.7 KiB
Go
225 lines
7.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
)
|
|
|
|
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, ts.URL+path, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if origin != "" {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
for k, v := range header {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp
|
|
}
|
|
|
|
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
preflight := map[string]string{
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "authorization, content-type",
|
|
}
|
|
|
|
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("app preflight: status %d", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
|
t.Errorf("app preflight: Allow-Origin %q", got)
|
|
}
|
|
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
|
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
|
}
|
|
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
|
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
|
}
|
|
|
|
// An ordinary request from the app can be read.
|
|
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
|
t.Errorf("app GET: Allow-Origin %q", got)
|
|
}
|
|
|
|
// Anybody else gets nothing, so the browser keeps them from reading.
|
|
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
|
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
|
}
|
|
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The app asks this before anyone has signed in, to tell a Verdande from a
|
|
// router's login page.
|
|
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
|
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("ping: %d", resp.StatusCode)
|
|
}
|
|
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
|
t.Errorf("ping = %v", body)
|
|
}
|
|
if _, ok := body["version"]; ok {
|
|
t.Error("ping reveals the version to anybody")
|
|
}
|
|
}
|
|
|
|
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
|
h := http.Header{}
|
|
if origin != "" {
|
|
h.Set("Origin", origin)
|
|
}
|
|
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
|
}
|
|
|
|
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
_, body := appLogin(t, ts)
|
|
token := body["session_token"].(string)
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Without a ticket or a cookie: refused.
|
|
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
|
t.Fatal("a socket opened with no credential")
|
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("no credential: %v", resp)
|
|
}
|
|
|
|
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
|
ticket, _ := body["ticket"].(string)
|
|
if resp.StatusCode != http.StatusOK || ticket == "" {
|
|
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
|
if err != nil {
|
|
t.Fatalf("dial with a ticket: %v", err)
|
|
}
|
|
defer conn.CloseNow()
|
|
|
|
// And it is the right person's stream: their own new project arrives on it.
|
|
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
|
_, msg, err := conn.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !strings.Contains(string(msg), "Live fra telefonen") {
|
|
t.Errorf("first event was %s", msg)
|
|
}
|
|
|
|
// Spent: the same ticket does not open a second socket.
|
|
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
|
t.Error("a ticket opened two sockets")
|
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("reused ticket: %v", resp)
|
|
}
|
|
}
|
|
|
|
func TestATicketExpires(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
_, body := appLogin(t, ts)
|
|
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
|
ticket := body["ticket"].(string)
|
|
|
|
ts.api.wsTickets.mu.Lock()
|
|
v := ts.api.wsTickets.m[ticket]
|
|
v.expires = time.Now().Add(-time.Second)
|
|
ts.api.wsTickets.m[ticket] = v
|
|
ts.api.wsTickets.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
|
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
|
t.Error("an expired ticket opened a socket")
|
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("expired ticket: %v", resp)
|
|
}
|
|
}
|
|
|
|
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
|
// on the cookie, and the instance's own page can.
|
|
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
var cookie string
|
|
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
|
if c.Name == sessionCookieInsecure {
|
|
cookie = c.Value
|
|
}
|
|
}
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
dial := func(origin string) error {
|
|
h := http.Header{}
|
|
h.Set("Origin", origin)
|
|
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
|
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
|
if err == nil {
|
|
conn.CloseNow()
|
|
}
|
|
return err
|
|
}
|
|
if err := dial("https://evil.example"); err == nil {
|
|
t.Error("a foreign page opened a socket on the cookie")
|
|
}
|
|
if err := dial("capacitor://localhost"); err == nil {
|
|
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
|
}
|
|
if err := dial("http://localhost"); err != nil {
|
|
t.Errorf("control: the instance's own origin: %v", err)
|
|
}
|
|
}
|
|
|
|
// The end-to-end suite runs the interface from a second host name and tells the
|
|
// server to count it as the app's. That origin is then the app's in every
|
|
// respect, and capacitor://localhost stays one too.
|
|
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
|
ts := newTestServerWith(t, func(c *config.Config) {
|
|
c.AppOrigins = []string{"http://app.localhost:8097"}
|
|
})
|
|
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
|
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Errorf("%s: Allow-Origin %q", origin, got)
|
|
}
|
|
}
|
|
// Not a prefix match: a neighbour on another port is nobody.
|
|
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("a neighbouring origin was answered: %q", got)
|
|
}
|
|
}
|