Files
five/rag/06-security.md
CharlesKWON f26911177f docs(rag): add security idioms & gotchas (06-security.md)
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>
2026-06-15 15:49:49 +09:00

5.7 KiB

doc, title, keywords, summary
doc title keywords summary
five-security Security idioms & gotchas for Five web apps
security
auth
session
csprng
crypto/rand
argon2
password hash
bluemonday
xss
sanitize
csp
security headers
rate limit
role gate
authorization
cookie
upload
sql injection
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.

// 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.

// 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.

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.
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.

// 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:

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