Capture the hardening patterns from the solmade audit so future Five work reuses them: authorize on resolved function name (not URL path), CSPRNG session tokens stored as hashes, argon2id with legacy-verify + upgrade, login rate-limit + timing-safe dummy hash, bluemonday HTML sanitize vs EscHtml, security headers + nonce CSP, upload allowlist (no SVG), bind-all SQL. Theme: thin Go RTL over an ecosystem crypto lib. INDEX/README updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
5.7 KiB
Markdown
122 lines
5.7 KiB
Markdown
---
|
|
doc: five-security
|
|
title: Security idioms & gotchas for Five web apps
|
|
keywords: [security, auth, session, csprng, crypto/rand, argon2, password hash, bluemonday, xss, sanitize, csp, security headers, rate limit, role gate, authorization, cookie, upload, sql injection]
|
|
summary: Hardening patterns and traps for a Five web app, grounded in the solmade codebase. Covers authz gating, session tokens, password hashing, XSS sanitization, headers/CSP, login rate-limiting, uploads — and the "thin Go RTL over an ecosystem crypto lib" pattern.
|
|
---
|
|
|
|
# Five web security — idioms & gotchas
|
|
|
|
Grounded in `solmade`. The recurring theme: reach for a **thin Go RTL wrapping a
|
|
battle-tested ecosystem library** (crypto/rand, bluemonday, argon2id) rather than
|
|
hand-rolling crypto in PRG.
|
|
|
|
## 1. GOTCHA — authorize on the RESOLVED function name, not the URL path
|
|
|
|
The router maps `-` and `/` both to `_` (`/api/admin/x`, `/api/admin-x`, `/api/admin_x`
|
|
all reach `ADMIN_X__MAIN`). A role gate that matches the path prefix `"/api/admin/"`
|
|
is bypassed by the hyphen/underscore variants → privilege escalation.
|
|
|
|
```five
|
|
// WRONG: SubStr(cPath,1,11) == "/api/admin/" ← misses /api/admin-x , /api/admin_x
|
|
// RIGHT: gate on the resolved function name
|
|
cFunc := PathToFunc( cPath )
|
|
IF Left( cFunc, 6 ) == "ADMIN_"
|
|
RETURN cRole == "superadmin" .OR. cRole == "operator"
|
|
ENDIF
|
|
```
|
|
Anon allowlists (login/logout/health) are safe as *exact* matches — they fail closed.
|
|
(solmade: `app/auth/auth_middleware.prg` RoleAllows.)
|
|
|
|
## 2. Session tokens — CSPRNG, and store the HASH
|
|
|
|
`hb_RandomInt` is Mersenne Twister (non-crypto) — predictable tokens → session
|
|
hijacking. Use a `crypto/rand` RTL. And store `SHA256(token)`, not the raw token, so a
|
|
DB leak yields nothing reusable; the cookie holds the raw value.
|
|
|
|
```five
|
|
// hbrtl_ext/secrand: SEC_RANDHEX(nBytes) via crypto/rand
|
|
FUNCTION SESSION_TOKEN() ; RETURN SEC_RANDHEX( 32 ) // 64-hex
|
|
FUNCTION SESSION_TOKEN_HASH( c ) ; RETURN hb_SHA256( hb_CStr( c ) )
|
|
// login : INSERT ... token = SESSION_TOKEN_HASH(cToken); Set-Cookie = raw cToken
|
|
// verify: WHERE s.token = SESSION_TOKEN_HASH(cCookie)
|
|
// logout: DELETE WHERE token = SESSION_TOKEN_HASH(cCookie)
|
|
```
|
|
Cookie flags: `HttpOnly; SameSite=Lax; Max-Age=…` and `Secure` when
|
|
`x-forwarded-proto == https`. (`hb_SHA256` == `shasum -a 256`, lowercase hex.)
|
|
|
|
## 3. Passwords — argon2id, with legacy verify + upgrade-on-login
|
|
|
|
Salted SHA-256 stretch is GPU-crackable (not memory-hard). Hash with argon2id
|
|
(`alexedwards/argon2id` via RTL). `PASSWD_VERIFY` detects the scheme so legacy
|
|
`$sha256s$` rows still log in; on success, transparently re-hash to argon2id.
|
|
|
|
```five
|
|
FUNCTION PASSWD_HASH( c ) // try SEC_ARGON2_HASH; fall back to legacy if empty
|
|
FUNCTION PASSWD_VERIFY( c, cEnc ) // Left(cEnc,9)=="$argon2id" → SEC_ARGON2_VERIFY else legacy
|
|
FUNCTION PASSWD_NEEDS_REHASH( cEnc ) ; RETURN Left(hb_CStr(cEnc),9) != "$argon2id"
|
|
// on login success: IF PASSWD_NEEDS_REHASH(stored) → UPDATE users SET password_hash=PASSWD_HASH(pw)
|
|
```
|
|
|
|
## 4. Login — rate limit + timing-safe unknown-user path
|
|
|
|
- Rate limit per IP: a `login_attempts` table; count failures in the last 15 min; ≥20 → 429.
|
|
Clear an IP's failures on success.
|
|
- Timing: an unknown email must NOT return before doing hash work, or response time leaks
|
|
which emails exist. Run a dummy hash on the not-found branch.
|
|
|
|
```five
|
|
IF aRows == NIL .OR. Len(aRows) == 0
|
|
PASSWD_VERIFY_DUMMY( cPass ) // burns argon2 work → constant-ish timing
|
|
RecordLogin( nPG, cIP, cEmail, .f. )
|
|
RETURN API_ERR( 401, "invalid credentials" )
|
|
ENDIF
|
|
```
|
|
Use the SAME error text/status for "no such user" and "wrong password".
|
|
|
|
## 5. XSS — sanitize user HTML; escape plain-text contexts
|
|
|
|
A CMS stores rich text (Editor.js: `<b><i><a>`). You cannot blanket-escape it (breaks
|
|
formatting) and you must not concat it raw into HTML (stored XSS). Sanitize with an
|
|
allowlist (`bluemonday` via RTL); escape only genuinely plain-text slots.
|
|
|
|
```five
|
|
// hbrtl_ext/sanitize: HTML_SANITIZE(html) via bluemonday.UGCPolicy()
|
|
cOut += '<p>' + HTML_SANITIZE( cUserText ) + '</p>' // rich text
|
|
cHtml += '<title>' + EscHtml( cPlainTitle ) + '</title>' // plain text
|
|
cImg += 'src="' + EscAttr( cUrl ) + '"' // attribute
|
|
```
|
|
Strips `<script>`, `on*=` handlers, `javascript:`; keeps `<b>`, links, lists.
|
|
|
|
## 6. Response headers + CSP
|
|
|
|
Set on every response: `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
|
|
`Referrer-Policy: strict-origin-when-cross-origin`. For a page that renders user content,
|
|
add a **per-request nonce CSP** so injected scripts can't run while your own data blocks
|
|
(JSON-LD) still do:
|
|
|
|
```five
|
|
cNonce := SEC_RANDHEX( 16 )
|
|
hHdrs[ "Content-Security-Policy" ] := ;
|
|
"default-src 'self'; img-src 'self' https: data:; " + ;
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + ;
|
|
"font-src 'self' https://fonts.gstatic.com; " + ;
|
|
"script-src 'nonce-" + cNonce + "'; object-src 'none'; base-uri 'none'"
|
|
// emit JSON-LD as: <script type="application/ld+json" nonce="<cNonce>">
|
|
```
|
|
A global CSP often breaks editor CDNs/inline styles — scope it to the rendered page.
|
|
|
|
## 7. Uploads
|
|
|
|
Extension **allowlist** (unknown → `.bin`), and **exclude `.svg`** (SVG can carry script
|
|
and would XSS if served inline). Strip path traversal from filenames (`..`, `/`, `\`, NUL)
|
|
and prefix with a server id so names aren't attacker-controlled.
|
|
|
|
## 8. SQL — always bind, never concat user input
|
|
|
|
`PG_QUERY(nPG, "… WHERE x=$1", { val })`. Bind every user value as `$1/$2…`. Only ever
|
|
concatenate *validated-allowlist* identifiers (table/column names), never raw input.
|
|
(See [[five-idioms]] for the Postgres patterns and the strings-not-ints column gotcha.)
|
|
|
|
Related: [[five-idioms]], [[five-gotchas]]
|