diff --git a/rag/06-security.md b/rag/06-security.md new file mode 100644 index 0000000..67aaa74 --- /dev/null +++ b/rag/06-security.md @@ -0,0 +1,121 @@ +--- +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 `