fix: harden credential and input handling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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}}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user