Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"],
"port": 5199
}
]
}
83 changes: 81 additions & 2 deletions internal/httpapi/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ package httpapi

import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
)

// requireAuth gates protected endpoints behind a static bearer token.
const sessionCookieName = "dm_session"

// requireAuth gates protected endpoints behind a static bearer token, supplied
// either as an Authorization header or as the HttpOnly session cookie set by
// the login endpoint.
//
// The token is supplied via the API_TOKEN environment variable. Local
// development can explicitly disable auth through AUTH_DISABLED=true.
func requireAuth(token string) func(http.Handler) http.Handler {
expected := []byte(token)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !validBearerToken(r.Header.Get("Authorization"), expected) {
if !validBearerToken(r.Header.Get("Authorization"), expected) && !validSessionRequest(r, expected) {
w.Header().Set("WWW-Authenticate", "Bearer")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
Expand All @@ -35,3 +40,77 @@ func validBearerToken(header string, expected []byte) bool {
}
return subtle.ConstantTimeCompare(presented, expected) == 1
}

// ponytail: the cookie stores the API token itself (HttpOnly, so scripts can't
// read it). Move to signed random session IDs if per-session revocation is needed.
func validSessionCookie(r *http.Request, expected []byte) bool {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(cookie.Value), expected) == 1
}

func validSessionRequest(r *http.Request, expected []byte) bool {
if !validSessionCookie(r, expected) {
return false
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
origin := strings.TrimSpace(r.Header.Get("Origin"))
return strings.HasPrefix(strings.ToLower(origin), "https://") && sameHostOrigin(origin, r.Host)
}
}

func sessionCookie(r *http.Request, auth AuthConfig, value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: !auth.Disabled || r.TLS != nil,
SameSite: http.SameSiteLaxMode,
}
}

// login exchanges the API token for the HttpOnly session cookie.
func login(auth AuthConfig) http.HandlerFunc {
expected := []byte(auth.Token)
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if !auth.Disabled && subtle.ConstantTimeCompare([]byte(body.Token), expected) != 1 {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
http.SetCookie(w, sessionCookie(r, auth, auth.Token, 30*24*60*60))
w.WriteHeader(http.StatusNoContent)
}
}

func logout(auth AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, sessionCookie(r, auth, "", -1))
w.WriteHeader(http.StatusNoContent)
}
}

// session reports whether the request is authenticated so the SPA can decide
// between the login page and the app shell without triggering a 401.
func session(auth AuthConfig) http.HandlerFunc {
expected := []byte(auth.Token)
return func(w http.ResponseWriter, r *http.Request) {
authenticated := auth.Disabled ||
validBearerToken(r.Header.Get("Authorization"), expected) ||
validSessionCookie(r, expected)
writeJSON(w, http.StatusOK, map[string]bool{"authenticated": authenticated})
}
}
95 changes: 95 additions & 0 deletions internal/httpapi/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand Down Expand Up @@ -90,6 +91,100 @@ func TestAuthRejectsQueryToken(t *testing.T) {
}
}

func TestLoginLogoutSessionFlow(t *testing.T) {
const token = "supersecrettoken123"
handler := New(nil, nil, nil, nil, nil, GitHubWebhookConfig{}, nil, "", AuthConfig{Token: token})

// Wrong token is rejected and sets no cookie.
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"token":"wrong"}`))
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for wrong login token, got %d", response.Code)
}
if len(response.Result().Cookies()) != 0 {
t.Fatal("failed login must not set a cookie")
}

// Correct token sets the HttpOnly session cookie.
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"token":"`+token+`"}`))
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("expected 204 for valid login, got %d", response.Code)
}
cookies := response.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure {
t.Fatalf("expected one HttpOnly Secure %s cookie, got %+v", sessionCookieName, cookies)
}

// The session endpoint recognizes the cookie.
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/session", nil)
request.AddCookie(cookies[0])
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":true`) {
t.Fatalf("expected authenticated session with cookie, got %d %s", response.Code, response.Body.String())
}

// Logout clears the cookie.
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/api/logout", nil)
handler.ServeHTTP(response, request)
cleared := response.Result().Cookies()
if len(cleared) != 1 || cleared[0].Value != "" || cleared[0].MaxAge >= 0 || !cleared[0].Secure {
t.Fatalf("expected logout to expire the session cookie, got %+v", cleared)
}

// Without a cookie the session reports unauthenticated.
response = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/session", nil)
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":false`) {
t.Fatalf("expected unauthenticated session without cookie, got %d %s", response.Code, response.Body.String())
}
}

func TestCookieAuthRequiresSameOriginForUnsafeMethods(t *testing.T) {
const token = "supersecrettoken123"
protected := requireAuth(token)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
tests := []struct {
name string
method string
origin string
bearer bool
want int
}{
{name: "safe cookie request", method: http.MethodGet, want: http.StatusNoContent},
{name: "same-origin cookie request", method: http.MethodPost, origin: "https://deploy.internal.prosights.co", want: http.StatusNoContent},
{name: "missing origin", method: http.MethodPost, want: http.StatusUnauthorized},
{name: "insecure same-host origin", method: http.MethodPost, origin: "http://deploy.internal.prosights.co", want: http.StatusUnauthorized},
{name: "cross-origin cookie request", method: http.MethodPost, origin: "https://other.prosights.co", want: http.StatusUnauthorized},
{name: "bearer automation", method: http.MethodPost, bearer: true, want: http.StatusNoContent},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(test.method, "https://deploy.internal.prosights.co/api/protected", nil)
if test.bearer {
request.Header.Set("Authorization", "Bearer "+token)
} else {
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
protected.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("expected %d, got %d", test.want, response.Code)
}
})
}
}

func TestValidBearerTokenConstantTime(t *testing.T) {
expected := []byte("supersecrettoken123")
cases := []struct {
Expand Down
10 changes: 9 additions & 1 deletion internal/httpapi/security.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package httpapi

import "net/http"
import (
"net/http"
"strings"
)

func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -12,3 +15,8 @@ func securityHeaders(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

func sameHostOrigin(origin string, host string) bool {
origin = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(origin, "http://"), "https://"))
return strings.EqualFold(origin, host)
}
4 changes: 4 additions & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func New(queries *db.Queries, tx transactionStarter, queue DeploymentQueue, logs
r.Get("/healthz", server.health)
r.Get("/readyz", server.readyz)
r.Get("/version", server.version)
// Tighter limit on login: it is the only credential-guessing surface.
r.With(rateLimit(20, 1*time.Minute)).Post("/login", login(auth))
r.Post("/logout", logout(auth))
r.Get("/session", session(auth))
r.Post("/webhooks/github", server.githubWebhook)
r.Post("/github/webhook", server.githubWebhook)
r.Get("/github/install/callback", server.githubInstallCallback)
Expand Down
5 changes: 0 additions & 5 deletions internal/httpapi/server_terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,11 +273,6 @@ func websocketUpgrader() websocket.Upgrader {
}
}

func sameHostOrigin(origin string, host string) bool {
origin = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(origin, "http://"), "https://"))
return strings.EqualFold(origin, host)
}

type terminalWriter struct {
mu sync.Mutex
conn *websocket.Conn
Expand Down
Loading
Loading