Documentation
¶
Index ¶
- Constants
- func ClearCSRFToken(w http.ResponseWriter, r *http.Request)
- func ConstantTimeCompare(a, b string) bool
- func IsSafeWasmURL(raw string) bool
- func IsValidFormPath(path string) bool
- func IssueCSRFToken(w http.ResponseWriter, r *http.Request) string
- func NewSessionClaims(userID, username, role string, vhosts []string) jwt.MapClaims
- func SameSiteFromEnv() http.SameSite
- func SanitizeDBError(err error) string
- func SessionCookie(r *http.Request, value string, maxAge int) *http.Cookie
- func SessionCookieMaxAge() int
- func ValidatePluginID(id string) bool
- func VerifyWebhookSignature(secret string, body []byte, signature string) bool
- type Handler
- func (h *Handler) AdminOnly(next http.HandlerFunc) http.Handler
- func (h *Handler) AuthMiddleware(next http.Handler) http.Handler
- func (h *Handler) BotProtectionCheck(r *http.Request, payload map[string]any, enable bool, minMs int, ...) error
- func (h *Handler) ClearWorkerDraining(id string)
- func (h *Handler) CorsMiddleware(next http.Handler) http.Handler
- func (h *Handler) CurrentSessionClaims(r *http.Request) (SessionClaims, error)
- func (h *Handler) EditorOnly(next http.HandlerFunc) http.Handler
- func (h *Handler) GetRoleAndVHosts(r *http.Request) (storage.Role, []string)
- func (h *Handler) HasVHostAccess(vhost string, allowedVHosts []string) bool
- func (h *Handler) HtmlEscape(s string) string
- func (h *Handler) IsFirstRun(ctx context.Context) bool
- func (h *Handler) IsOriginAllowed(origin, referer, allowed string) bool
- func (h *Handler) IsRateLimited(r *http.Request, sourceID string, limit int) bool
- func (h *Handler) IsWorkerDraining(id string) bool
- func (h *Handler) JsonError(w http.ResponseWriter, msg string, code int)
- func (h *Handler) MarkWorkerDraining(id string)
- func (h *Handler) ParseCommonFilter(r *http.Request) storage.CommonFilter
- func (h *Handler) RbacMiddleware(requiredRole storage.Role) func(http.Handler) http.Handler
- func (h *Handler) RecordAuditLog(r *http.Request, level, message, action string, ...)
- func (h *Handler) RecoverMiddleware(next http.Handler) http.Handler
- func (h *Handler) RevocationRefreshRunning() bool
- func (h *Handler) SecurityHeadersMiddleware(next http.Handler) http.Handler
- func (h *Handler) SessionRevoker() *Revoker
- func (h *Handler) StartRateLimitCleanup()
- func (h *Handler) StartSessionRevocation(ctx context.Context)
- func (h *Handler) StopSessionRevocation()
- func (h *Handler) StoreGuardMiddleware(next http.Handler) http.Handler
- func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request)
- func (h *Handler) WakeUpWorkflow(ctx context.Context, resourceType string, path string) bool
- func (h *Handler) WantsHTML(r *http.Request) bool
- type LoginAttempt
- type Revoker
- func (r *Revoker) IsRevoked(claims SessionClaims) bool
- func (r *Revoker) Prune()
- func (r *Revoker) Refresh(ctx context.Context) error
- func (r *Revoker) Revoke(ctx context.Context, tokenID string, expiresAt time.Time) error
- func (r *Revoker) RevokeUser(ctx context.Context, userID string) error
- func (r *Revoker) Size() int
- func (r *Revoker) StartRefreshing(ctx context.Context, interval time.Duration) (stop func())
- type SessionClaims
- type WorkerUpdater
Constants ¶
const ( // MaxLoginAttempts is the number of consecutive failed login attempts // allowed before an account/IP combination is temporarily locked out. MaxLoginAttempts = 5 // LoginLockoutDuration is how long a locked account/IP must wait before // it is allowed to attempt logging in again. LoginLockoutDuration = 15 * time.Minute // LoginAttemptWindow is the period of inactivity after which the failed // attempt counter is reset automatically. LoginAttemptWindow = 15 * time.Minute )
const ( // CSRFCookieName holds the token. Deliberately NOT HttpOnly: the UI has to // read it to echo it back. That is safe because the token is not a // credential on its own — it only proves the request came from a context // that could read same-origin cookies. CSRFCookieName = "hermod_csrf" // CSRFHeaderName is where the client echoes it. CSRFHeaderName = "X-CSRF-Token" )
CSRF protection, double-submit style.
The API authenticates with a cookie, and a browser attaches cookies to cross-site requests it was tricked into making. SameSite is currently the only thing between that and a state change — adequate at Lax or Strict, and nothing at all the moment a deployment sets None to allow cross-origin embedding.
Double submit: the server issues a readable token in a cookie, the client echoes it in a header, and the two must match. It works because an attacker on another origin can make the browser *send* the cookie but cannot read it to populate the header — and cannot set custom headers cross-origin at all.
const ( // SessionTTL is how long any single token is valid. SessionTTL = time.Hour // MaxSessionAge caps how long a session may be renewed for in total, // measured from the original login. Past it, renewal stops and the user // authenticates again — otherwise sliding renewal is an eternal session. MaxSessionAge = 24 * time.Hour // SessionStartClaim carries the original login time across renewals. It is // what MaxSessionAge is measured against; without it each renewal would // reset the clock. SessionStartClaim = "sst" )
Session lifetime.
The session is a stateless JWT, so expiring the cookie at logout ends it for that browser but does not revoke the token — a copy captured beforehand stays valid until it expires. Real revocation needs server-side session state, which is recorded in SECURITY.md as outstanding.
What does not need that is the *size* of the window. A token used to be valid for 24 hours, which is exactly how long a stolen one stayed useful. Cutting that to an hour and renewing on activity leaves the experience unchanged — an active session never expires under the user — while reducing a captured token from a day of access to about an hour.
Renewal alone would let a session live forever, so there are two bounds.
const ( // DefaultRefreshInterval bounds how long a revocation takes to reach other // instances. Short enough that a compromised session is not usable for long, // long enough that the store is not hammered. DefaultRefreshInterval = 10 * time.Second )
Session revocation.
The session is a stateless JWT: a valid signature means accepted, so ending one early needs state somewhere. The obvious place is a lookup per request — and that is exactly what the auth middleware was built to avoid, deriving the user from the claims specifically so an authenticated call costs no I/O.
So the state lives in memory and is *replicated* through the store rather than *read* from it. IsRevoked is a map lookup; the store is how a revocation reaches other instances, on a background refresh.
The cost of that choice, stated plainly: a revocation is immediate on the instance that performed it and takes up to RefreshInterval to reach the others. Paying for a store lookup on every authenticated request would close that window and put I/O back on the hot path. This is the trade, and it is the reason the window is short rather than absent.
const (
// maxPluginWasmSize is the maximum allowed size for a plugin WASM file (10MB).
MaxPluginWasmSize = 10 << 20
)
const SessionCookieName = "hermod_session"
SessionCookieName is the only cookie carrying a Hermod session.
const (
UserContextKey contextKey = "user"
)
Variables ¶
This section is empty.
Functions ¶
func ClearCSRFToken ¶
func ClearCSRFToken(w http.ResponseWriter, r *http.Request)
ClearCSRFToken expires the token cookie. Called alongside logout so a stale token does not outlive the session it belonged to.
func ConstantTimeCompare ¶
ConstantTimeCompare compares two strings in constant time.
func IsSafeWasmURL ¶
IsSafeWasmURL only permits http(s) URLs that do not target loopback, link-local, or otherwise private/internal addresses (SSRF protection).
func IsValidFormPath ¶
IsValidFormPath reports whether the supplied path is safe to reflect back in a generated form page without risking XSS or other injection.
func IssueCSRFToken ¶
func IssueCSRFToken(w http.ResponseWriter, r *http.Request) string
IssueCSRFToken generates a token, sets it as a readable cookie, and returns it. Call it wherever a session begins.
Secure mirrors the session cookie's own logic so the pair behave alike across HTTP and HTTPS deployments; SameSite is deliberately Lax rather than Strict, because the token has to survive a top-level navigation back into the app or the first state-changing request after one fails.
func NewSessionClaims ¶
NewSessionClaims builds the claim set for a freshly issued session. Callers that mint a token at login should use this so every issue site agrees on the lifetime and carries the session-start marker renewal depends on.
func SameSiteFromEnv ¶
func SanitizeDBError ¶
SanitizeDBError strips network identifiers (IP addresses, hostnames and ports) from a database connection error so they are never exposed to clients.
func SessionCookie ¶
SessionCookie builds the session cookie. maxAge is the lifetime in seconds, or -1 to delete.
One builder for every site that sets or clears it — login, 2FA, renewal and logout. A cookie is only replaced when its name, path and domain match, so a logout or renewal that spelled any of them differently would leave the original in place and silently fail to take effect.
gosec cannot prove Secure is set because it is derived from the request scheme. That is deliberate: pinning it true would stop the cookie working over plain HTTP on localhost, which is how the dev stack runs. It is forced true for SameSite=None, where browsers require it.
func SessionCookieMaxAge ¶
func SessionCookieMaxAge() int
SessionCookieMaxAge is the cookie lifetime that matches the token's.
func ValidatePluginID ¶
ValidatePluginID ensures the identifier is safe to use as a filename and prevents directory traversal or shell injection.
func VerifyWebhookSignature ¶
VerifyWebhookSignature validates an incoming webhook signature against the configured secret using constant-time HMAC-SHA256 comparison. It accepts both the GitHub-style "sha256=<hex>" prefixed value and a bare hex digest.
Types ¶
type Handler ¶
type Handler struct {
Storage storage.Storage
LogStorage storage.Storage
Registry *registry.Registry
Worker WorkerUpdater
AI *ai.SelfHealingService
Config *config.Config
ConfigPath string
FileStorage filestorage.Storage
// StoreMu guards concurrent reads/writes to storage during hot-swap.
StoreMu sync.RWMutex
// readiness debounce state
ReadyMu sync.Mutex
LastReadyStatus bool
LastReadyStatusSet bool
LastReadyStatusAt time.Time
// Common state for middleware
FormRateLimit sync.Map
RateLimitOnce sync.Once
RateLimitQuit chan struct{}
// LoginAttempts tracks failed login attempts keyed by username+client IP
// to enforce account lockout after too many failures.
LoginAttempts sync.Map
// DrainingWorkers tracks worker IDs for which an administrator has requested
// a graceful shutdown. Workers learn of the request when they poll their own
// record (the flag is surfaced as storage.Worker.Draining on API responses).
DrainingWorkers sync.Map
// Revoker ends sessions before their token expires. Lazily initialised by
// SessionRevoker so a zero-value Handler — which the tests use throughout —
// still authenticates rather than panicking.
Revoker *Revoker
// contains filtered or unexported fields
}
func (*Handler) BotProtectionCheck ¶
func (*Handler) ClearWorkerDraining ¶
ClearWorkerDraining removes any pending shutdown request for the given worker.
func (*Handler) CurrentSessionClaims ¶
func (h *Handler) CurrentSessionClaims(r *http.Request) (SessionClaims, error)
CurrentSessionClaims returns the validated claims of the request's session.
The middleware has already parsed them to authenticate the request, but does not put them in the context — only the derived user. Logout needs the token ID and expiry, which the user does not carry, so it parses once more rather than widening what every request stores.
func (*Handler) EditorOnly ¶
func (h *Handler) EditorOnly(next http.HandlerFunc) http.Handler
func (*Handler) GetRoleAndVHosts ¶
func (*Handler) HasVHostAccess ¶
func (*Handler) HtmlEscape ¶
func (*Handler) IsOriginAllowed ¶
func (*Handler) IsRateLimited ¶
func (*Handler) IsWorkerDraining ¶
IsWorkerDraining reports whether a graceful shutdown has been requested for the given worker.
func (*Handler) JsonError ¶
func (h *Handler) JsonError(w http.ResponseWriter, msg string, code int)
func (*Handler) MarkWorkerDraining ¶
MarkWorkerDraining records that a graceful shutdown has been requested for the given worker so the next time it polls its own record it begins draining.
func (*Handler) ParseCommonFilter ¶
func (h *Handler) ParseCommonFilter(r *http.Request) storage.CommonFilter
func (*Handler) RbacMiddleware ¶
func (*Handler) RecordAuditLog ¶
func (*Handler) RecoverMiddleware ¶
func (*Handler) RevocationRefreshRunning ¶
RevocationRefreshRunning reports whether the refresher is live.
func (*Handler) SecurityHeadersMiddleware ¶
func (*Handler) SessionRevoker ¶
SessionRevoker returns the handler's revoker, creating it on first use.
It is wired to the state store when there is one, so a revocation reaches other instances; without one it still revokes locally, which is the whole deployment in the default single-instance case.
func (*Handler) StartRateLimitCleanup ¶
func (h *Handler) StartRateLimitCleanup()
func (*Handler) StartSessionRevocation ¶
StartSessionRevocation begins keeping this instance's revocation list in step with the others and bounded. Idempotent.
Without it, a revocation performed here never reaches another instance and nothing ever prunes, so the list grows for the life of the process.
func (*Handler) StopSessionRevocation ¶
func (h *Handler) StopSessionRevocation()
StopSessionRevocation stops the refresher and waits for it to finish. Safe to call when it was never started, and safe to call twice: shutdown paths do.
func (*Handler) StoreGuardMiddleware ¶
func (*Handler) UploadFile ¶
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request)
uploadFile handles multipart file uploads, sanitizes the filename, and stores the file using the configured file storage, returning the path or URI.
func (*Handler) WakeUpWorkflow ¶
type LoginAttempt ¶
LoginAttempt holds the failed-login bookkeeping for a single key.
type Revoker ¶
type Revoker struct {
// contains filtered or unexported fields
}
Revoker decides whether a session has been ended before its token expires.
func NewRevoker ¶
func NewRevoker(store hermod.StateStore) *Revoker
NewRevoker builds a Revoker. store may be nil.
func (*Revoker) IsRevoked ¶
func (r *Revoker) IsRevoked(claims SessionClaims) bool
IsRevoked reports whether the session behind these claims has been ended.
This is on the hot path for every authenticated request, so it does no I/O and takes only a read lock.
func (*Revoker) Prune ¶
func (r *Revoker) Prune()
Prune drops entries whose token would have expired anyway. Without it the list grows for the life of the process.
func (*Revoker) Refresh ¶
Refresh pulls revocations recorded by other instances into memory, and removes entries the store no longer needs to hold. Call it periodically; StartRefreshing does that.
Its cost is a function of what changed, not of everything ever revoked. An entry is immutable once written, so an entry already in memory is never read again — an idle refresh is a single read of the index no matter how long the process has been up.
func (*Revoker) Revoke ¶
Revoke ends a single session. expiresAt should be the token's own expiry, so the entry can be dropped once it stops mattering.
The local revocation takes effect even when replication fails, and the error still reports the failure — the operator needs to know it did not propagate, but the session the user just ended must not keep working here.
func (*Revoker) RevokeUser ¶
RevokeUser ends every session a user currently holds, and no future one.
This is the instrument for a password change or a compromised account: a per-session list cannot express it, because nobody enumerated the sessions.
type SessionClaims ¶
type SessionClaims struct {
UserID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
VHosts []string `json:"vhosts"`
// TokenID names this specific session so it can be revoked on its own.
// Empty on tokens issued before revocation existed; those are reachable
// only through RevokeUser, which matches on SessionStart instead.
TokenID string `json:"jti,omitempty"`
// SessionStart is when the original login happened, carried across sliding
// renewals. It is what RevokeUser compares against, and what stops a
// whole-user revocation from also invalidating the login that follows it.
SessionStart time.Time `json:"-"`
jwt.RegisteredClaims
}
func (*SessionClaims) UnmarshalJSON ¶
func (c *SessionClaims) UnmarshalJSON(data []byte) error
UnmarshalJSON decodes SessionStart from the numeric "sst" claim, which the standard unmarshaller cannot map onto a time.Time.