Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/config"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// WebRoutes is implemented by the HTML handler set, so the router can mount the
|
||||
// browser interface without importing the web package (which imports this one).
|
||||
type WebRoutes interface {
|
||||
LoginPage(http.ResponseWriter, *http.Request)
|
||||
Login(http.ResponseWriter, *http.Request)
|
||||
Logout(http.ResponseWriter, *http.Request)
|
||||
OAuthStart(http.ResponseWriter, *http.Request)
|
||||
OAuthCallback(http.ResponseWriter, *http.Request)
|
||||
LandingPage(http.ResponseWriter, *http.Request)
|
||||
HowItWorksPage(http.ResponseWriter, *http.Request)
|
||||
PublicAPIPage(http.ResponseWriter, *http.Request)
|
||||
TokensPage(http.ResponseWriter, *http.Request)
|
||||
CreateToken(http.ResponseWriter, *http.Request)
|
||||
RevokeToken(http.ResponseWriter, *http.Request)
|
||||
Dashboard(http.ResponseWriter, *http.Request)
|
||||
ItemsPage(http.ResponseWriter, *http.Request)
|
||||
ItemDetail(http.ResponseWriter, *http.Request)
|
||||
Neighbourhood(http.ResponseWriter, *http.Request)
|
||||
DownloadOriginal(http.ResponseWriter, *http.Request)
|
||||
AddTextPage(http.ResponseWriter, *http.Request)
|
||||
AddText(http.ResponseWriter, *http.Request)
|
||||
UploadPage(http.ResponseWriter, *http.Request)
|
||||
Upload(http.ResponseWriter, *http.Request)
|
||||
DeleteItem(http.ResponseWriter, *http.Request)
|
||||
ReprocessItem(http.ResponseWriter, *http.Request)
|
||||
SearchPage(http.ResponseWriter, *http.Request)
|
||||
ReviewPage(http.ResponseWriter, *http.Request)
|
||||
ConfirmRelationship(http.ResponseWriter, *http.Request)
|
||||
RejectRelationship(http.ResponseWriter, *http.Request)
|
||||
ChangeRelationshipType(http.ResponseWriter, *http.Request)
|
||||
CreateRelationship(http.ResponseWriter, *http.Request)
|
||||
EntitiesPage(http.ResponseWriter, *http.Request)
|
||||
EntityDetail(http.ResponseWriter, *http.Request)
|
||||
ConfirmAlias(http.ResponseWriter, *http.Request)
|
||||
RejectAlias(http.ResponseWriter, *http.Request)
|
||||
JobsPage(http.ResponseWriter, *http.Request)
|
||||
JobsTable(http.ResponseWriter, *http.Request)
|
||||
RetryJob(http.ResponseWriter, *http.Request)
|
||||
SettingsPage(http.ResponseWriter, *http.Request)
|
||||
Reembed(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// RouterConfig holds what the router needs to wire the application together.
|
||||
type RouterConfig struct {
|
||||
Cfg *config.Config
|
||||
Auth *auth.Service
|
||||
Tokens *auth.TokenService
|
||||
API *Handlers
|
||||
Web WebRoutes
|
||||
Health *HealthChecker
|
||||
Metrics *observability.Metrics
|
||||
Static http.Handler
|
||||
Logger interface{ Handler() any }
|
||||
}
|
||||
|
||||
// NewRouter builds the complete HTTP handler.
|
||||
//
|
||||
// Route registration is explicit rather than pattern-driven, so that the exact
|
||||
// authentication, CSRF and body-size treatment of every endpoint is readable in
|
||||
// one place.
|
||||
func NewRouter(rc RouterConfig) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
logging := Logging{Metrics: rc.Metrics}
|
||||
authn := Authenticator{Auth: rc.Auth, LoginPath: "/login"}
|
||||
apiAuthn := Authenticator{Auth: rc.Auth, Tokens: rc.Tokens, APIMode: true}
|
||||
|
||||
csrf := CSRF{
|
||||
TrustedOrigins: trustedOrigins(rc.Cfg),
|
||||
HeaderName: "X-CSRF-Token",
|
||||
FieldName: "csrf_token",
|
||||
}
|
||||
|
||||
// --- Health and metrics -------------------------------------------------
|
||||
//
|
||||
// Health endpoints are unauthenticated so an orchestrator can reach them,
|
||||
// but they expose only status, never workspace content.
|
||||
mux.Handle("GET /health/live", wrap(rc, "/health/live", http.HandlerFunc(rc.Health.Live)))
|
||||
mux.Handle("GET /health/ready", wrap(rc, "/health/ready", http.HandlerFunc(rc.Health.Ready)))
|
||||
|
||||
if rc.Cfg.HTTP.MetricsEnabled && rc.Metrics != nil {
|
||||
// Metrics describe the service, not stored knowledge. They are bound to
|
||||
// the same loopback listener as everything else.
|
||||
mux.Handle("GET /metrics", promhttp.HandlerFor(rc.Metrics.Registry, promhttp.HandlerOpts{
|
||||
ErrorHandling: promhttp.ContinueOnError,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Static assets ------------------------------------------------------
|
||||
mux.Handle("GET /static/", staticHandler(rc.Static))
|
||||
|
||||
// --- REST API -----------------------------------------------------------
|
||||
//
|
||||
// API routes authenticate from the session cookie and enforce CSRF on
|
||||
// state-changing methods, exactly as the browser interface does.
|
||||
// Every API route names the scope it needs. CSRF is applied unconditionally
|
||||
// because the middleware skips safe methods itself and exempts bearer
|
||||
// requests — so no route can be registered having forgotten it.
|
||||
//
|
||||
// RequireScope subsumes the old RequireWrite: the scope check is the
|
||||
// intersection of the caller's role and, for a token, its granted scopes.
|
||||
apiChain := func(route string, h http.HandlerFunc, scope domain.TokenScope) http.Handler {
|
||||
return Chain(h,
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
apiAuthn.RequireAuth(),
|
||||
csrf.Middleware(),
|
||||
RequireScope(scope),
|
||||
)
|
||||
}
|
||||
|
||||
// Token management is deliberately not scoped: it requires an interactive
|
||||
// session, so a leaked token cannot mint its own replacements.
|
||||
tokenChain := func(route string, h http.HandlerFunc) http.Handler {
|
||||
return Chain(h,
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
apiAuthn.RequireAuth(),
|
||||
csrf.Middleware(),
|
||||
RequireSession(),
|
||||
)
|
||||
}
|
||||
|
||||
a := rc.API
|
||||
mux.Handle("POST /api/v1/items", apiChain("/api/v1/items", a.CreateItem, domain.ScopeIngest))
|
||||
mux.Handle("POST /api/v1/items/upload", apiChain("/api/v1/items/upload", a.UploadItem, domain.ScopeIngest))
|
||||
mux.Handle("GET /api/v1/items", apiChain("/api/v1/items", a.ListItems, domain.ScopeQuery))
|
||||
mux.Handle("GET /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.GetItem, domain.ScopeQuery))
|
||||
mux.Handle("DELETE /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.DeleteItem, domain.ScopeIngest))
|
||||
mux.Handle("POST /api/v1/items/{id}/reprocess", apiChain("/api/v1/items/{id}/reprocess", a.ReprocessItem, domain.ScopeIngest))
|
||||
mux.Handle("GET /api/v1/items/{id}/relationships", apiChain("/api/v1/items/{id}/relationships", a.GetItemRelationships, domain.ScopeQuery))
|
||||
|
||||
mux.Handle("GET /api/v1/search", apiChain("/api/v1/search", a.Search, domain.ScopeQuery))
|
||||
// Answering reads; it creates nothing. It needs the query scope, not ingest.
|
||||
mux.Handle("POST /api/v1/search/answer", apiChain("/api/v1/search/answer", a.Answer, domain.ScopeQuery))
|
||||
|
||||
mux.Handle("GET /api/v1/relationships", apiChain("/api/v1/relationships", a.ListRelationships, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/relationships", apiChain("/api/v1/relationships", a.CreateRelationship, domain.ScopeReview))
|
||||
mux.Handle("PATCH /api/v1/relationships/{id}", apiChain("/api/v1/relationships/{id}", a.PatchRelationship, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/relationships/{id}/confirm", apiChain("/api/v1/relationships/{id}/confirm", a.ConfirmRelationship, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/relationships/{id}/reject", apiChain("/api/v1/relationships/{id}/reject", a.RejectRelationship, domain.ScopeReview))
|
||||
|
||||
mux.Handle("GET /api/v1/entities", apiChain("/api/v1/entities", a.ListEntities, domain.ScopeQuery))
|
||||
mux.Handle("GET /api/v1/entities/{id}", apiChain("/api/v1/entities/{id}", a.GetEntity, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/aliases/{id}/confirm", apiChain("/api/v1/aliases/{id}/confirm", a.ConfirmAlias, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/aliases/{id}/reject", apiChain("/api/v1/aliases/{id}/reject", a.RejectAlias, domain.ScopeReview))
|
||||
|
||||
mux.Handle("GET /api/v1/jobs", apiChain("/api/v1/jobs", a.ListJobs, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/jobs/{id}/retry", apiChain("/api/v1/jobs/{id}/retry", a.RetryJob, domain.ScopeIngest))
|
||||
|
||||
mux.Handle("GET /api/v1/tokens", tokenChain("/api/v1/tokens", a.ListTokens))
|
||||
mux.Handle("POST /api/v1/tokens", tokenChain("/api/v1/tokens", a.CreateToken))
|
||||
mux.Handle("DELETE /api/v1/tokens/{id}", tokenChain("/api/v1/tokens/{id}", a.RevokeToken))
|
||||
|
||||
// --- Browser interface --------------------------------------------------
|
||||
webChain := func(route string, h http.HandlerFunc, opts webOpts) http.Handler {
|
||||
mw := []Middleware{
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
}
|
||||
if opts.authenticated {
|
||||
mw = append(mw, authn.RequireAuth())
|
||||
} else if opts.optionalAuth {
|
||||
mw = append(mw, authn.OptionalAuth())
|
||||
}
|
||||
if opts.csrf {
|
||||
mw = append(mw, csrf.Middleware())
|
||||
}
|
||||
if opts.write {
|
||||
mw = append(mw, RequireWrite())
|
||||
}
|
||||
return Chain(h, mw...)
|
||||
}
|
||||
|
||||
pub := webOpts{}
|
||||
read := webOpts{authenticated: true}
|
||||
write := webOpts{authenticated: true, csrf: true, write: true}
|
||||
|
||||
wb := rc.Web
|
||||
mux.Handle("GET /login", webChain("/login", wb.LoginPage, pub))
|
||||
// The password endpoint is registered only when local login is enabled, so
|
||||
// a disabled break-glass path is genuinely absent rather than present and
|
||||
// refusing. There is no handler to reach and nothing to probe.
|
||||
if rc.Cfg.Auth.LocalLoginEnabled {
|
||||
// It has its own CSRF handling: no session exists yet, so the
|
||||
// session-token check cannot apply.
|
||||
mux.Handle("POST /login", webChain("/login", wb.Login, pub))
|
||||
}
|
||||
mux.Handle("POST /logout", webChain("/logout", wb.Logout, webOpts{authenticated: true, csrf: true}))
|
||||
|
||||
// Federated sign-in. Both are public: the first has no session yet, and the
|
||||
// second is where the provider sends the browser back to.
|
||||
mux.Handle("GET /auth/oauth/start", webChain("/auth/oauth/start", wb.OAuthStart, pub))
|
||||
mux.Handle("GET "+config.CallbackPath, webChain(config.CallbackPath, wb.OAuthCallback, pub))
|
||||
|
||||
// The public site. These pages read nothing from the database.
|
||||
mux.Handle("GET /how-it-works", webChain("/how-it-works", wb.HowItWorksPage, pub))
|
||||
mux.Handle("GET /api", webChain("/api", wb.PublicAPIPage, pub))
|
||||
|
||||
// The only dual-purpose route: the dashboard when signed in, the landing
|
||||
// page when not. OptionalAuth rather than RequireAuth, so an anonymous
|
||||
// visitor is served rather than redirected.
|
||||
mux.Handle("GET /{$}", webChain("/", wb.LandingPage, webOpts{optionalAuth: true}))
|
||||
mux.Handle("GET /items", webChain("/items", wb.ItemsPage, read))
|
||||
mux.Handle("GET /items/{id}", webChain("/items/{id}", wb.ItemDetail, read))
|
||||
mux.Handle("GET /items/{id}/neighbourhood", webChain("/items/{id}/neighbourhood", wb.Neighbourhood, read))
|
||||
mux.Handle("GET /items/{id}/download", webChain("/items/{id}/download", wb.DownloadOriginal, read))
|
||||
mux.Handle("POST /items/{id}/delete", webChain("/items/{id}/delete", wb.DeleteItem, write))
|
||||
mux.Handle("POST /items/{id}/reprocess", webChain("/items/{id}/reprocess", wb.ReprocessItem, write))
|
||||
|
||||
mux.Handle("GET /add", webChain("/add", wb.AddTextPage, read))
|
||||
mux.Handle("POST /add", webChain("/add", wb.AddText, write))
|
||||
mux.Handle("GET /upload", webChain("/upload", wb.UploadPage, read))
|
||||
mux.Handle("POST /upload", webChain("/upload", wb.Upload, write))
|
||||
|
||||
mux.Handle("GET /search", webChain("/search", wb.SearchPage, read))
|
||||
|
||||
mux.Handle("GET /review", webChain("/review", wb.ReviewPage, read))
|
||||
mux.Handle("POST /relationships/create", webChain("/relationships/create", wb.CreateRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/confirm", webChain("/relationships/{id}/confirm", wb.ConfirmRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/reject", webChain("/relationships/{id}/reject", wb.RejectRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/type", webChain("/relationships/{id}/type", wb.ChangeRelationshipType, write))
|
||||
|
||||
mux.Handle("GET /entities", webChain("/entities", wb.EntitiesPage, read))
|
||||
mux.Handle("GET /entities/{id}", webChain("/entities/{id}", wb.EntityDetail, read))
|
||||
mux.Handle("POST /aliases/{id}/confirm", webChain("/aliases/{id}/confirm", wb.ConfirmAlias, write))
|
||||
mux.Handle("POST /aliases/{id}/reject", webChain("/aliases/{id}/reject", wb.RejectAlias, write))
|
||||
|
||||
mux.Handle("GET /jobs", webChain("/jobs", wb.JobsPage, read))
|
||||
mux.Handle("GET /jobs/table", webChain("/jobs/table", wb.JobsTable, read))
|
||||
mux.Handle("POST /jobs/{id}/retry", webChain("/jobs/{id}/retry", wb.RetryJob, write))
|
||||
|
||||
mux.Handle("GET /settings", webChain("/settings", wb.SettingsPage, read))
|
||||
mux.Handle("POST /settings/reembed", webChain("/settings/reembed", wb.Reembed, write))
|
||||
|
||||
mux.Handle("GET /settings/tokens", webChain("/settings/tokens", wb.TokensPage, read))
|
||||
mux.Handle("POST /settings/tokens", webChain("/settings/tokens", wb.CreateToken, write))
|
||||
mux.Handle("POST /settings/tokens/{id}/revoke", webChain("/settings/tokens/{id}/revoke", wb.RevokeToken, write))
|
||||
|
||||
// Anything unmatched. The handler distinguishes API paths from browser
|
||||
// paths so a mistyped API URL gets JSON, not HTML.
|
||||
mux.Handle("/", Chain(http.HandlerFunc(notFound),
|
||||
RequestID(), Recover(), logging.Middleware("not_found"), SecurityHeaders(false)))
|
||||
|
||||
// The outermost body limit applies to every route. Upload routes raise it
|
||||
// themselves, since a document is legitimately larger than a JSON request.
|
||||
return MaxBody(maxBodyFor(rc.Cfg))(mux)
|
||||
}
|
||||
|
||||
type webOpts struct {
|
||||
authenticated bool
|
||||
// optionalAuth resolves a session when one is present but serves anonymous
|
||||
// callers rather than redirecting them.
|
||||
optionalAuth bool
|
||||
csrf bool
|
||||
write bool
|
||||
}
|
||||
|
||||
// maxBodyFor returns the global request-body ceiling.
|
||||
//
|
||||
// It is the larger of the JSON limit and the upload limit plus multipart
|
||||
// overhead, because one middleware guards both kinds of route.
|
||||
func maxBodyFor(cfg *config.Config) int64 {
|
||||
limit := cfg.HTTP.MaxRequestBytes
|
||||
if upload := cfg.Uploads.MaxUploadBytes + (2 << 20); upload > limit {
|
||||
limit = upload
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// wrap applies the standard middleware to an unauthenticated endpoint.
|
||||
func wrap(rc RouterConfig, route string, h http.Handler) http.Handler {
|
||||
logging := Logging{Metrics: rc.Metrics}
|
||||
return Chain(h, RequestID(), Recover(), logging.Middleware(route), SecurityHeaders(false))
|
||||
}
|
||||
|
||||
// staticHandler serves embedded assets with long-lived caching.
|
||||
//
|
||||
// Static files carry no user content, so they are the one place where caching
|
||||
// is safe; the no-store default set by SecurityHeaders is overridden here.
|
||||
func staticHandler(fs http.Handler) http.Handler {
|
||||
return http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Reject any path attempting to escape the asset root. http.FS already
|
||||
// refuses these, but failing early keeps the intent explicit.
|
||||
if strings.Contains(r.URL.Path, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
fs.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// notFound answers unmatched routes in the right format.
|
||||
func notFound(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusNotFound, Code: CodeNotFound,
|
||||
Message: "No such endpoint. The API is versioned under /api/v1.",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html lang="en-GB"><head><meta charset="utf-8">` +
|
||||
`<title>Not found — Relata</title><link rel="stylesheet" href="/static/app.css"></head>` +
|
||||
`<body><main class="page"><div class="error-page"><h1>Page not found</h1>` +
|
||||
`<p>There is nothing at this address.</p>` +
|
||||
`<p><a class="btn" href="/">Back to the dashboard</a></p></div></main></body></html>`))
|
||||
}
|
||||
|
||||
// trustedOrigins derives the origins permitted to submit state-changing
|
||||
// requests, from the configured base URL and listen address.
|
||||
func trustedOrigins(cfg *config.Config) []string {
|
||||
origins := []string{}
|
||||
if base := strings.TrimRight(cfg.HTTP.BaseURL, "/"); base != "" {
|
||||
origins = append(origins, base)
|
||||
}
|
||||
// The listen address is also accepted, so that reaching Relata directly at
|
||||
// http://127.0.0.1:8080 works when BaseURL names something else.
|
||||
addr := cfg.HTTP.Addr
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "127.0.0.1" + addr
|
||||
}
|
||||
for _, scheme := range []string{"http://", "https://"} {
|
||||
o := scheme + addr
|
||||
if !containsStr(origins, o) {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
// localhost and 127.0.0.1 are the same host to a person but different
|
||||
// origins to a browser.
|
||||
if strings.HasPrefix(addr, "127.0.0.1:") {
|
||||
port := strings.TrimPrefix(addr, "127.0.0.1")
|
||||
origins = append(origins, "http://localhost"+port, "https://localhost"+port)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func containsStr(list []string, v string) bool {
|
||||
for _, e := range list {
|
||||
if e == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user