CI / test (push) Successful in 1m9s
Every key needed an operator with a shell. This adds the login system that made that necessary, which is the thing I argued against building until it was asked for — so it is built as if it will be attacked, because a public login form is. Signing up produces an account that can do nothing. Approval is what makes it work and stays on the CLI: having a shell on the control plane is what makes someone an administrator, and that is a smaller thing to get right than roles and an admin UI that mints credentials. The details that matter: Passwords are argon2id, where a slow hash is correct — the input is chosen by a human and has a dictionary, which is the opposite of an API key. Both kinds of authentication now live in this codebase and they are in separate packages so neither set of assumptions leaks into the other. Length is the only rule: composition rules push people towards Password1! and cost more entropy than they buy. Signup never says whether an address already has an account, and sign-in answers the same way for a wrong password and a missing account — verifying against a dummy hash when there is no user, so a stopwatch cannot enumerate accounts. Account state is checked after the password, not before, so guessing an address reveals nothing about it. Guessing is bounded per address rather than per IP: an attacker has many addresses to come from and one to guess against, and limiting by IP locks out an office behind one NAT. Sessions are server-side, so signing out and suspending take effect immediately rather than whenever a token expires; suspension is checked in the same query that resolves the session, because a second round trip is a check somebody eventually forgets. Every state-changing form carries a CSRF token verified against a hash in the session — a cookie authenticates the browser, not the page that asked. Key ownership is enforced in the UPDATE rather than by reading first, so a guessed key id from another account changes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
2.4 KiB
Go
87 lines
2.4 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// LandingData is what the landing page shows. It is deliberately almost
|
|
// nothing: this page is served without a key, to anyone who can reach the
|
|
// address.
|
|
type LandingData struct {
|
|
BaseURL string
|
|
AuthEnabled bool
|
|
SignupOpen bool
|
|
Contact string
|
|
ContactHref string
|
|
Notice string
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// Landing serves the page at "/" on the client-facing port.
|
|
//
|
|
// Without it that path is a 404, which is what someone sees when they paste the
|
|
// address into a browser — an endpoint that looks broken rather than one that
|
|
// tells them how to use it.
|
|
//
|
|
// It lists no models and no cluster state. Everything that says what is here
|
|
// stays behind the key, including /v1/models.
|
|
func Landing(version string, authEnabled, signupOpen bool, contact, notice string) http.HandlerFunc {
|
|
text, href := contactLink(contact)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Only the root: a catch-all would turn every mistyped path into this
|
|
// page, hiding genuine 404s from anyone debugging a client.
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
render(w, "landing", LandingData{
|
|
BaseURL: baseURL(r),
|
|
AuthEnabled: authEnabled,
|
|
SignupOpen: signupOpen,
|
|
Contact: text,
|
|
ContactHref: href,
|
|
Notice: notice,
|
|
Version: version,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
// baseURL reconstructs the address the client actually used.
|
|
//
|
|
// Behind a reverse proxy the scheme and host this process sees are the internal
|
|
// ones, so the forwarded headers are what make the copy-pasteable example on
|
|
// the page correct. They are attacker-controlled when nothing sets them, which
|
|
// is why they are only reflected into a displayed URL and never used for a
|
|
// decision.
|
|
func baseURL(r *http.Request) string {
|
|
scheme := "http"
|
|
switch {
|
|
case r.TLS != nil:
|
|
scheme = "https"
|
|
case r.Header.Get("X-Forwarded-Proto") != "":
|
|
scheme = firstValue(r.Header.Get("X-Forwarded-Proto"))
|
|
}
|
|
|
|
host := r.Host
|
|
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
|
host = firstValue(fwd)
|
|
}
|
|
if host == "" {
|
|
host = "localhost:8080"
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// firstValue takes the first entry of a comma-separated forwarded header: a
|
|
// chain of proxies appends rather than replaces.
|
|
func firstValue(v string) string {
|
|
if i := strings.IndexByte(v, ','); i >= 0 {
|
|
v = v[:i]
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|