Skip to content

Security

Stoa ships with security defaults that protect both the Admin Panel and the Storefront against common web vulnerabilities.

HTTP Security Headers

Stoa sets the following HTTP security headers on every response via a global middleware in internal/server/server.go:

HeaderValuePurpose
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing — the browser must use the declared Content-Type
X-Frame-OptionsDENYBlocks the page from being embedded in a <frame>, <iframe>, or <object> (clickjacking protection)
X-XSS-Protection1; mode=blockActivates the legacy XSS auditor in older browsers and blocks the page on detection
Referrer-Policystrict-origin-when-cross-originSends the full URL for same-origin requests; only the origin for cross-origin HTTPS requests; nothing for cross-origin HTTP
Content-Security-Policydefault-src 'self'Baseline CSP for API routes — see Content Security Policy for the full nonce-based policy applied to HTML pages
Permissions-Policycamera=(), microphone=(), geolocation=()Disables access to the device camera, microphone, and geolocation APIs for all browsing contexts, including embedded iframes

Permissions-Policy

Setting camera=(), microphone=(), and geolocation=() with empty allowlists means the features are disabled entirely — no origin, including the page's own origin, is granted access. This prevents malicious third-party scripts or iframes from requesting sensitive device permissions on behalf of the store.

Content Security Policy (CSP)

Stoa enforces a nonce-based Content Security Policy on every HTML page response. This prevents cross-site scripting (XSS) by ensuring only explicitly trusted scripts can execute.

How it works

Each time the Admin Panel or Storefront serves index.html, Stoa:

  1. Generates a cryptographically random 128-bit nonce (base64-encoded)
  2. Injects the nonce into the Content-Security-Policy response header
  3. Adds a nonce attribute to every <script> tag in the HTML

The resulting CSP header looks like:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-<random>' 'strict-dynamic';
  style-src 'self' 'unsafe-inline'

'strict-dynamic'

The 'strict-dynamic' directive means that any script loaded by a nonced (trusted) script automatically inherits trust. This is critical for the plugin system — plugins load Web Components and external scripts (e.g. Stripe's js.stripe.com/v3/) dynamically via document.createElement('script'), and 'strict-dynamic' ensures these work without maintaining an explicit allowlist in script-src.

Plugin compatibility

Plugin developers do not need to do anything special for CSP. Scripts loaded dynamically from a trusted (nonced) bootstrap script inherit trust automatically via 'strict-dynamic'.

Plugin external scripts

Plugins that declare ExternalScripts in their UI extensions still have their domains added to script-src, frame-src, and connect-src as a CSP Level 2 fallback for older browsers that don't support 'strict-dynamic'.

What stays 'unsafe-inline'

style-src retains 'unsafe-inline' because:

  • Many CSS-in-JS patterns and framework-generated inline styles require it
  • Inline styles do not pose the same XSS risk as inline scripts
  • Noncing every <style> tag would add complexity without meaningful security gain

API routes

API routes (/api/v1/*) use a separate, stricter CSP (default-src 'self') since they return JSON, not HTML.

File Upload Validation

Stoa validates uploaded files server-side using magic byte detection, preventing attackers from bypassing restrictions by spoofing the Content-Type header.

How it works

When a file is uploaded via POST /api/v1/admin/media:

  1. The first 512 bytes of the file are read
  2. Go's http.DetectContentType inspects the magic bytes to determine the actual MIME type
  3. The detected type is checked against an allowlist of permitted MIME types
  4. If the type is not allowed, the request is rejected with 415 Unsupported Media Type

The client-provided Content-Type header is ignored for type determination — the server always trusts the file content over the header.

Allowed MIME types

MIME TypeDescription
image/jpegJPEG images
image/pngPNG images
image/gifGIF images
image/webpWebP images
image/svg+xmlSVG vector graphics
application/pdfPDF documents

Extending the allowlist

To allow additional file types, add entries to the allowedMIMETypes map in internal/domain/media/handler.go.

SVG handling

SVGs are detected by http.DetectContentType as text/xml or text/plain since they are XML-based text files. Stoa applies a special fallback: if the client header declares image/svg+xml and the detected type is text/xml or text/plain, the file is accepted as SVG.

Error response

Rejected uploads return:

json
{
  "errors": [
    {
      "code": "unsupported_media_type",
      "detail": "file type not allowed"
    }
  ]
}

Media Delete Path Validation

When a media file is deleted via DELETE /api/v1/admin/media/:id, Stoa resolves the stored relative path to an absolute filesystem path. To prevent directory traversal attacks, LocalStorage.Delete() applies a two-stage validation before calling os.Remove.

Stage 1 — Regex format check

The path must match the exact format produced by Store():

YYYY/MM/xxxxxxxx-filename
SegmentRule
YYYYFour decimal digits (year)
MMTwo decimal digits (month)
xxxxxxxxExactly 8 lowercase hex characters (first 8 chars of a UUID v4)
filenameOne or more characters (the original filename)

The regex enforced is ^\d{4}/\d{2}/[0-9a-f]{8}-.+$. Any path that does not match — including empty strings, bare filenames, and paths with uppercase characters in the prefix — is rejected before any filesystem access.

Stage 2 — Path containment check

Even after the regex passes, Stoa resolves the path to its absolute form and verifies it remains inside the configured basePath:

go
absBase, _ := filepath.Abs(basePath)
absPath, _ := filepath.Abs(filepath.Join(basePath, filepath.Clean(relPath)))

if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) {
    return "", fmt.Errorf("path escapes base directory: %q", relPath)
}

This is a defense-in-depth measure. Even a path that somehow passes the regex but contains encoded or OS-specific traversal sequences (e.g. backslash sequences on Windows) cannot escape the upload directory.

Why S3 is not affected

S3Storage uses object keys, not filesystem paths. S3 keys are opaque strings — there is no concept of directory traversal on the S3 API. Path validation applies only to LocalStorage.

Error responses

ConditionError message
Path does not match YYYY/MM/xxxxxxxx-filenameinvalid media path format: "<path>"
Resolved path escapes basePathpath escapes base directory: "<path>"
File does not existSilent success (idempotent delete)

Both error conditions cause Delete() to return an error, which the media handler translates to 400 Bad Request.

Related

See Media for a full reference of the media storage system, including storage backends, the Store() path format, and image processing.

Database Connection Security

Stoa defaults to sslmode=require for PostgreSQL connections, ensuring credentials and query data are encrypted in transit. At startup, Stoa checks the connection's TLS configuration and logs a warning if SSL is disabled:

WRN database connection uses sslmode=disable — not recommended for production

For local development with Docker Compose, sslmode=disable is set via the STOA_DATABASE_URL environment variable — this is safe because traffic stays within Docker's internal bridge network.

See Database SSL/TLS for all available SSL modes.

Token Blacklisting

Stoa maintains an in-memory blacklist for revoked JWT access tokens. When a user logs out, the access token's JTI (unique token ID) is added to the blacklist, making it immediately unusable — even before the token's natural expiry.

How it works

  1. On logout, the access token from the Authorization header is parsed and its JTI + expiry time are stored in the blacklist
  2. Both Authenticate and OptionalAuth middleware check the blacklist before accepting a Bearer token
  3. Blacklisted tokens in Authenticate return 401 Unauthorized with "token has been revoked"
  4. Blacklisted tokens in OptionalAuth are treated as anonymous (the request continues without auth context)
  5. A background goroutine cleans up expired entries every minute — since access tokens live at most 15 minutes, memory usage stays minimal

Why in-memory?

Access tokens have a maximum lifetime of 15 minutes. An in-memory data structure with TTL-based cleanup is sufficient — there's no need for Redis or database storage. The blacklist is protected by a read-write mutex for concurrent access.

Scaling note

In a multi-instance deployment, each instance maintains its own blacklist. A token blacklisted on one instance won't be rejected by another. For single-instance deployments (the typical Stoa setup), this is not an issue. For multi-instance setups, consider placing a shared cache (e.g. Redis) behind a custom middleware.

Rate Limiting

Stoa applies rate limiting at two levels:

Global rate limit

A global IP-based rate limit applies to all API requests. The default is 300 requests per minute per IP with a burst allowance of 50.

Endpoint-specific rate limits

Sensitive endpoints have dedicated, stricter rate limits on top of the global limit. These protect against credential stuffing, account enumeration, and checkout abuse:

EndpointDefault LimitDescription
POST /api/v1/auth/login10 req/min per IPPrevents brute-force login attempts
POST /api/v1/store/register5 req/min per IPPrevents mass account creation
POST /api/v1/store/checkout10 req/min per IPPrevents checkout abuse
GET /api/v1/store/orders/:id/transactions10 req/min per IPPrevents guest token guessing

When the limit is exceeded, the server responds with 429 Too Many Requests and includes a Retry-After header indicating how many seconds to wait before retrying.

json
HTTP/1.1 429 Too Many Requests
Retry-After: 42

{
  "error": "Too Many Requests"
}

Configuration

All rate limits are configurable in config.yaml:

yaml
security:
  rate_limit:
    requests_per_minute: 300  # Global limit
    burst: 50
    login:
      requests_per_minute: 10
    register:
      requests_per_minute: 5
    checkout:
      requests_per_minute: 10
    guest_order:
      requests_per_minute: 10

Or via environment variables:

bash
STOA_SECURITY_RATE_LIMIT_LOGIN_REQUESTS_PER_MINUTE=10
STOA_SECURITY_RATE_LIMIT_REGISTER_REQUESTS_PER_MINUTE=5
STOA_SECURITY_RATE_LIMIT_CHECKOUT_REQUESTS_PER_MINUTE=10
STOA_SECURITY_RATE_LIMIT_GUEST_ORDER_REQUESTS_PER_MINUTE=10

TIP

Endpoint-specific limits are independent — exhausting the login limit does not affect /refresh or /logout. Each IP address has its own counter.

Brute-force protection

In addition to IP-based rate limiting, Stoa has email-based brute-force protection on the login endpoint: after 5 failed attempts for the same email, the account is locked for 60 minutes. This works in tandem with rate limiting — rate limits protect against credential stuffing across different emails, while brute-force protection guards individual accounts.

The account-lock response always returns a fixed Retry-After: 3600 header regardless of the actual remaining lockout time. This prevents attackers from using the header value to determine exactly when a locked account becomes available again.

Guest Token Security

Guest orders use a cryptographically strong token for ownership verification. The token is never exposed in the API response body — it is delivered exclusively via an HTTP-only cookie.

Token generation

Each guest checkout generates a 32-byte random token using crypto/rand, hex-encoded to 64 characters. This provides 256 bits of entropy, making brute-force guessing infeasible.

The guest token is set as the stoa_guest_token cookie on the checkout response:

AttributeValueReason
HttpOnlytruePrevents JavaScript access (XSS protection)
SameSiteLaxAllows payment provider redirects (e.g. Stripe 3D Secure)
SecureMatches CSRF configSet when serving over HTTPS
Path/api/v1/storeScoped to store API routes
MaxAge30 daysCovers typical order lifecycle

The browser automatically includes this cookie on subsequent store API requests (e.g. fetching payment transactions), so guest ownership verification works transparently without exposing the token to client-side JavaScript.

Store vs. Admin API

  • Store API — The checkout response includes "is_guest_order": true instead of the raw token. The guest token cookie handles authentication.
  • Admin API — Returns the full guest_token field for debugging and payment reconciliation.

JWT Secret Validation

Stoa validates the JWT signing secret at startup and refuses to start if the secret is insecure.

Requirements

CheckBehavior
Default value change-me-in-productionStartup aborted with error
Length < 32 bytesStartup aborted with error
Length 32–63 bytesStartup proceeds with a warning
Length >= 64 bytesNo warning

Generating a secure secret

bash
# Generate a 64-byte random secret (recommended)
openssl rand -base64 64

Set via environment variable or config.yaml:

bash
STOA_AUTH_JWT_SECRET="your-secure-secret-here"

If the secret fails validation, you'll see an error like:

jwt: default secret 'change-me-in-production' must not be used — set auth.jwt_secret in config

or:

jwt: secret must be at least 32 bytes, got 12

WARNING

The default value change-me-in-production is publicly known and must never be used outside of local development. Stoa will refuse to start with this value.

Email Normalization

All email addresses are normalized to lowercase with trimmed whitespace before any database lookup, uniqueness check, or brute-force tracking. This prevents inconsistencies between the application layer and the database.

Why this matters

Without normalization, an attacker could bypass account-level brute-force protection by varying the case of an email address — user@example.com, User@Example.COM, and USER@EXAMPLE.COM would each get their own failure counter, effectively tripling the number of allowed attempts before lockout.

Additionally, case-sensitive email uniqueness checks could allow duplicate accounts for the same mailbox (e.g. Alice@test.com and alice@test.com).

How it works

The auth.NormalizeEmail function is applied at every entry point that handles emails:

LayerWhereEffect
LoginAuth handlerEmail is normalized before DB lookup and brute-force tracking
Brute-force trackerIsLocked, RecordFailure, RecordSuccessAll case variants map to the same tracker key
Customer serviceCreate, Update, GetByEmail, VerifyCredentialsEmails are stored and queried in lowercase
Admin CLIstoa admin createAdmin email is normalized before INSERT

Database safety net

As an additional safeguard, the database enforces case-insensitive uniqueness via functional indexes:

sql
CREATE UNIQUE INDEX idx_admin_users_email_lower ON admin_users (LOWER(email));
CREATE UNIQUE INDEX idx_customers_email_lower ON customers (LOWER(email));

These indexes replace the original UNIQUE constraints on the email column. Even if a code path were to skip normalization, the database would reject case-variant duplicates.

Migration

Migration 000011_email_case_insensitive normalizes all existing emails to lowercase and creates the functional indexes. Run stoa migrate up to apply.

Authentication

See Authentication for details on JWT access/refresh tokens, RBAC roles, and CSRF protection.

CSRF Protection

Stoa uses the Double Submit Cookie pattern. State-changing requests (POST, PUT, PATCH, DELETE) must include an X-CSRF-Token header matching the csrf_token cookie value.

Two categories of requests are exempt from the CSRF check:

CategoryReason
Requests with an Authorization header (Bearer or ApiKey)Cross-origin requests cannot set custom headers, so CSRF is impossible by design.
Plugin webhook paths (/plugins/{name}/webhooks/*)Webhooks authenticate via provider-specific signatures (e.g. Stripe HMAC), not cookies.

All other plugin routes — /plugins/{name}/admin/*, /plugins/{name}/store/*, and /plugins/{name}/assets/* — require a valid CSRF token like any other cookie-authenticated request.

Plugin developers

Only /plugins/{name}/webhooks/* paths are CSRF-exempt. If your plugin registers admin or store endpoints under any other path pattern, those endpoints are protected by CSRF and your clients must send the X-CSRF-Token header on state-changing requests — unless they authenticate via an Authorization header instead.

Password Hashing

All passwords are hashed with Argon2id, the recommended algorithm for password storage per OWASP guidelines.

Timing-safe login

Login requests always perform a full Argon2id hash comparison, regardless of whether the user exists. When a login attempt targets a non-existent email address, Stoa verifies the submitted password against a pre-computed dummy hash instead of returning immediately. This ensures both code paths — user found with wrong password and user not found — take the same amount of time, preventing timing-based user enumeration.

Without this mitigation, an attacker could measure response times to determine which email addresses are registered: a fast response would indicate "user not found" (no hash computation), while a slow response would indicate "user exists" (Argon2id ran). The dummy hash uses the same DefaultParams (64 MB memory, 3 iterations) as real password hashes, making the timing indistinguishable.

Released under the APACHE 2.0 License.