--- 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: ``). 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 += '

' + HTML_SANITIZE( cUserText ) + '

' // rich text cHtml += '' + EscHtml( cPlainTitle ) + '' // plain text cImg += 'src="' + EscAttr( cUrl ) + '"' // attribute ``` Strips `