fix: harden credential and input handling

This commit is contained in:
Andy Dixon
2026-08-10 16:15:41 +01:00
parent 00ef697b03
commit ab181453f0
19 changed files with 6100 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
// Relata's only client-side script.
//
// It does two small things that plain HTML cannot: confirm destructive actions,
// and let HTMX requests carry the CSRF token. Everything else is server
// rendered. There is no inline script anywhere, which is what allows the
// Content-Security-Policy to forbid it outright.
(function () {
"use strict";
// Confirm destructive submissions. The prompt text lives in the markup so
// that it is translatable and reviewable alongside the form it guards.
document.addEventListener("submit", function (event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) return;
var message = form.getAttribute("data-confirm");
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
// HTMX issues requests outside the normal form flow, so the CSRF token has to
// be attached explicitly. It is read from the form field the server rendered,
// never from a cookie.
// Copy-to-clipboard for a freshly issued API token. Delegated, so it works
// for content rendered after load. The value is read from the DOM only; it is
// never sent anywhere.
document.addEventListener("click", function (event) {
var trigger = event.target.closest("[data-copy]");
if (!trigger) {
return;
}
var source = document.getElementById(trigger.getAttribute("data-copy"));
if (!source) {
console.warn("Copy control has no matching source element");
return;
}
var original = trigger.textContent;
function showStatus(message) {
trigger.textContent = message;
window.setTimeout(function () {
trigger.textContent = original;
}, 2000);
}
if (!navigator.clipboard) {
console.warn("Clipboard API is unavailable in this browser context");
showStatus("Copy unavailable");
return;
}
navigator.clipboard.writeText(source.textContent.trim()).then(function () {
showStatus("Copied");
}).catch(function () {
// Do not log the clipboard value or the full error object: the source is
// a bearer credential and browser errors add no actionable context here.
console.warn("Clipboard write was refused");
showStatus("Copy failed");
});
});
document.body.addEventListener("htmx:configRequest", function (event) {
var field = document.querySelector('input[name="csrf_token"]');
if (field && field.value) {
event.detail.headers["X-CSRF-Token"] = field.value;
}
});
})();
+114
View File
@@ -0,0 +1,114 @@
{{define "content"}}
<h1>API tokens</h1>
<p class="lede">
Non-interactive credentials for <code>/api/v1</code>. They authenticate programs, not people.
</p>
{{if .NewSecret}}
<section class="panel panel-highlight">
<h2>Your new token{{if .NewName}}: {{.NewName}}{{end}}</h2>
<p class="callout">
<strong>This is the only time it will be shown.</strong> Relata stores only a hash of it and
cannot show it again. Copy it now.
</p>
<pre class="secret"><code id="new-token">{{.NewSecret}}</code></pre>
<p>
<button type="button" class="btn" data-copy="new-token">Copy to clipboard</button>
</p>
<p class="muted">
The value exists only in this response and is not included in the page URL. Copy it before
navigating away; refreshing the page will not recover the same secret.
</p>
</section>
{{end}}
<section class="panel">
<h2>Create a token</h2>
<form method="post" action="/settings/tokens" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" maxlength="80" required
placeholder="e.g. nightly ingestion script">
<p class="hint">So you can tell your tokens apart later.</p>
</div>
<fieldset class="field">
<legend>Scopes</legend>
{{range .Scopes}}
<label class="check">
<input type="checkbox" name="scopes" value="{{.Scope}}" {{if not .Available}}disabled{{end}}>
<span><strong>{{.Label}}</strong> — {{.Description}}
{{if not .Available}}<em class="muted">(not available to your role)</em>{{end}}</span>
</label>
{{end}}
<p class="hint">Grant only what the program needs. Scopes narrow permission; they never widen it.</p>
</fieldset>
<div class="field">
<label for="ttl_days">Expires after (days)</label>
<input type="number" id="ttl_days" name="ttl_days" min="1" max="{{.MaxTTL}}" value="{{.DefaultTTL}}">
<p class="hint">Maximum {{.MaxTTL}} days. A token stops working at its expiry with no further action.</p>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Create token</button>
<a class="btn btn-quiet" href="/settings">Back to settings</a>
</div>
</form>
</section>
<section class="panel">
<h2>Your tokens</h2>
{{if .Tokens}}
<table class="data">
<thead>
<tr>
<th>Name</th><th>Prefix</th><th>Scopes</th>
<th>Created</th><th>Expires</th><th>Last used</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{{range .Tokens}}
<tr {{if or .Revoked .Expired}}class="row-inactive"{{end}}>
<td>{{.Token.Name}}</td>
<td><code>{{.Token.Prefix}}…</code></td>
<td>{{.Scopes}}</td>
<td>{{.Token.CreatedAt.Format "2 Jan 2006"}}</td>
<td>{{if .Token.ExpiresAt.IsZero}}never{{else}}{{.Token.ExpiresAt.Format "2 Jan 2006"}}{{end}}</td>
<td>{{if .Token.LastUsedAt}}{{.Token.LastUsedAt.Format "2 Jan 2006 15:04"}}{{else}}never{{end}}</td>
<td><span class="status status-{{if .Revoked}}rejected{{else if .Expired}}muted{{else}}confirmed{{end}}">{{.Status}}</span></td>
<td>
{{if not .Revoked}}
<form method="post" action="/settings/tokens/{{.Token.ID}}/revoke"
data-confirm="Revoke this token? Any program using it will stop working immediately.">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-quiet btn-danger">Revoke</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Revoked and expired tokens stay listed. Hiding them would make this page a worse record of
what existed than the audit log.
</p>
{{else}}
<p class="empty">You have no API tokens. Create one above to use the API.</p>
{{end}}
</section>
<section class="panel panel-quiet">
<h2>Using a token</h2>
<pre><code>curl -H "Authorization: Bearer $RELATA_TOKEN" \
'{{"https://your-host/api/v1/search?q=delivery"}}'</code></pre>
<p class="muted">
Send it in the <code>Authorization</code> header only — never in a query parameter, where it
would end up in access logs, browser history and referrer headers.
<a href="/api">Full API description</a>.
</p>
</section>
{{end}}
+188
View File
@@ -0,0 +1,188 @@
package web
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// tokenRow is one token as shown on the page.
type tokenRow struct {
Token *domain.APIToken
Status string
Expired bool
Revoked bool
Scopes string
}
// scopeChoice is one selectable scope on the creation form.
type scopeChoice struct {
Scope domain.TokenScope
Label string
Description string
Available bool
}
// TokensPage lists the caller's API tokens and offers the creation form.
func (h *Handlers) TokensPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.renderTokensPage(w, r, p, "", "", http.StatusOK)
}
// renderTokensPage renders the token list and, after creation, the one-time
// secret directly in the POST response. A bearer credential must never travel
// through a redirect URL, where it would enter browser history and proxy logs.
func (h *Handlers) renderTokensPage(w http.ResponseWriter, r *http.Request, p *domain.Principal,
newSecret, newName string, status int) {
toks, err := h.d.Tokens.List(r.Context(), p)
if err != nil {
if newSecret == "" {
h.renderError(w, r, http.StatusInternalServerError, "Could not list tokens",
"The token list could not be read. Please try again.")
return
}
// Creation has already succeeded and the secret is unrecoverable. Show it
// even if the follow-up list refresh failed rather than losing it forever.
toks = nil
}
now := time.Now().UTC()
rows := make([]tokenRow, 0, len(toks))
for _, t := range toks {
row := tokenRow{Token: t, Status: "Active", Scopes: joinScopes(t.Scopes)}
switch {
case t.RevokedAt != nil:
row.Status, row.Revoked = "Revoked", true
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
row.Status, row.Expired = "Expired", true
}
rows = append(rows, row)
}
data := struct {
base
Tokens []tokenRow
Scopes []scopeChoice
// NewSecret is set only immediately after creation.
NewSecret string
NewName string
DefaultTTL int
MaxTTL int
}{
base: h.newBase(r, "API tokens", "settings"),
Tokens: rows,
Scopes: scopeChoicesFor(p.Role),
NewSecret: newSecret,
NewName: newName,
DefaultTTL: int(h.d.Cfg.Auth.OAuth.TokenDefaultTTL.Hours() / 24),
MaxTTL: int(h.d.Cfg.Auth.OAuth.TokenMaxTTL.Hours() / 24),
}
if err != nil {
data.Error = "The token was created, but the token list could not be refreshed. Copy the new token now."
}
h.d.Renderer.Render(w, r, "tokens.html", status, data)
}
// CreateToken issues a token from the interface.
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
scopes := make([]domain.TokenScope, 0, 3)
for _, raw := range r.PostForm["scopes"] {
sc := domain.TokenScope(strings.TrimSpace(raw))
if sc.Valid() {
scopes = append(scopes, sc)
}
}
days, _ := strconv.Atoi(r.PostFormValue("ttl_days"))
ttl := time.Duration(days) * 24 * time.Hour
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
Name: r.PostFormValue("name"),
Scopes: scopes,
TTL: ttl,
})
if err != nil {
observability.LoggerFrom(r.Context()).Warn("token creation refused", "error", err.Error())
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenCreated, "api_token", issued.Token.ID, "success",
map[string]string{"name": issued.Token.Name, "prefix": issued.Token.Prefix})
// Render the one-time secret in this response. Redirect URLs must never carry
// bearer credentials: URLs are retained by browsers, proxies and access logs.
h.renderTokensPage(w, r, p, issued.Secret, issued.Token.Name, http.StatusCreated)
}
// RevokeToken makes a token unusable from the interface.
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
id := r.PathValue("id")
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
http.Redirect(w, r, "/settings/tokens?ok=token_revoked", http.StatusSeeOther)
}
// scopeChoicesFor describes the scopes, marking those the role cannot grant.
//
// An unavailable scope is shown and disabled rather than hidden, so a viewer
// can see that ingestion exists and why it is not offered to them.
func scopeChoicesFor(role domain.Role) []scopeChoice {
return []scopeChoice{
{
Scope: domain.ScopeQuery, Label: "Query",
Description: "Search, generate answers, and read stored items, entities and relationships.",
Available: domain.RoleGrants(role, domain.ScopeQuery),
},
{
Scope: domain.ScopeIngest, Label: "Ingest",
Description: "Add items, upload files, reprocess and delete.",
Available: domain.RoleGrants(role, domain.ScopeIngest),
},
{
Scope: domain.ScopeReview, Label: "Review",
Description: "Confirm, reject and retype relationships; confirm entity aliases.",
Available: domain.RoleGrants(role, domain.ScopeReview),
},
}
}
func joinScopes(scopes []domain.TokenScope) string {
if len(scopes) == 0 {
return "none"
}
parts := make([]string, 0, len(scopes))
for _, s := range scopes {
parts = append(parts, string(s))
}
return strings.Join(parts, ", ")
}
+102
View File
@@ -0,0 +1,102 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
)
type tokenPageRepo struct {
domain.APITokenRepository
tokens []*domain.APIToken
}
func (r *tokenPageRepo) Create(_ context.Context, tok *domain.APIToken) error {
r.tokens = append(r.tokens, tok)
return nil
}
func (r *tokenPageRepo) ListForUser(_ context.Context, workspaceID, userID string) ([]*domain.APIToken, error) {
var out []*domain.APIToken
for _, tok := range r.tokens {
if tok.WorkspaceID == workspaceID && tok.UserID == userID {
out = append(out, tok)
}
}
return out, nil
}
type tokenPageRels struct{ domain.RelationshipRepository }
func (tokenPageRels) CountPendingReview(context.Context, string) (int64, error) { return 0, nil }
func newTokenPageHandlers(t *testing.T) (*Handlers, *domain.Principal) {
t.Helper()
renderer, err := NewRenderer()
if err != nil {
t.Fatal(err)
}
repo := &tokenPageRepo{}
tokens := auth.NewTokenService(repo, nil, auth.TokenConfig{
DefaultTTL: 24 * time.Hour,
MaxTTL: 30 * 24 * time.Hour,
})
cfg := &config.Config{}
cfg.Auth.OAuth.TokenDefaultTTL = 24 * time.Hour
cfg.Auth.OAuth.TokenMaxTTL = 30 * 24 * time.Hour
h := NewHandlers(Deps{Cfg: cfg, Renderer: renderer, Tokens: tokens, Rels: tokenPageRels{}})
p := &domain.Principal{
UserID: "user-1", Username: "admin", WorkspaceID: "workspace-1", Role: domain.RoleAdmin,
}
return h, p
}
func TestCreateTokenNeverPlacesSecretInRedirectURL(t *testing.T) {
h, p := newTokenPageHandlers(t)
form := url.Values{
"name": {"automation"},
"scopes": {string(domain.ScopeQuery)},
"ttl_days": {"7"},
}
r := httptest.NewRequest(http.MethodPost, "/settings/tokens", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(api.WithPrincipal(r.Context(), p))
w := httptest.NewRecorder()
h.CreateToken(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", w.Code)
}
if location := w.Header().Get("Location"); location != "" {
t.Fatalf("token creation redirected to %q; the secret could enter URL logs", location)
}
if !strings.Contains(w.Body.String(), "rlt_v1_") {
t.Fatal("the one-time token was not rendered in the creation response")
}
if strings.Contains(w.Body.String(), "?secret=") {
t.Fatal("the rendered page still describes a secret-bearing URL")
}
}
func TestTokensPageIgnoresSecretQueryParameter(t *testing.T) {
h, p := newTokenPageHandlers(t)
r := httptest.NewRequest(http.MethodGet, "/settings/tokens?secret=rlt_v1_attacker_controlled", nil)
r = r.WithContext(api.WithPrincipal(r.Context(), p))
w := httptest.NewRecorder()
h.TokensPage(w, r)
if strings.Contains(w.Body.String(), "rlt_v1_attacker_controlled") {
t.Fatal("a secret query parameter was reflected into the token page")
}
}