Skip to content

Întărituri de securitate: plafon la upload, CSP pe pagina randată, timp de login

Vitalie requested to merge 25_security-hardening into main

Cele trei puncte din #25 (closed), fiecare în commit-ul lui.

1. Plafon la încărcarea de imagini

multer era configurat fără limits, deci POST /api/admin/uploads accepta un fișier de orice mărime: un singur admin autentificat putea umple discul serverului cu o cerere, iar un disc plin înseamnă SQLite care nu mai scrie, adică magazin care nu mai primește comenzi.

15 MB, pe fișierul care sosește, nu pe cel publicat: tot ce trece e re-encodat sub 300 KB, iar originalul se șterge în aceeași cerere. Issue-ul propunea 10 MB, ales după cel mai mare banner real — dar bannerul nu e ce se încarcă; sursele pe care le alege un operator (o poză de pe telefon, un PNG exportat din unealta de design) trec de 10 MB destul de des.

Testul prinde și jumătatea ușor de ratat: octeții care apucaseră să sosească nu rămân pe disc.

2. Pagina randată pleacă acum cu un CSP scris pentru magazin

Antetul pe care îl scrie helmet descrie JSON-ul acestui API — default-src 'self' și nimic altceva — iar trimis cu HTML-ul magazinului blochează fiecare apel către api.hyperice.md: pagina desenează antetul și subsolul, apoi rămâne goală. De aceea ruta îl ștergea. Acum îl înlocuiește.

Fiecare sursă din lib/csp.ts a fost măsurată pe magazinul care rulează:

  • gazda API-ului vine din /config.js al frontendului, exact valoarea pe care o folosește și browserul — nu o a doua variabilă care poate să se depărteze de ea;
  • 'unsafe-inline' la stiluri, pentru că React scrie atribute style pentru carusel, gradientul de hero și cursoarele proprii;
  • googletagmanager și gazdele regionale google-analytics, acolo unde merge măsurarea după acceptul vizitatorului;
  • fără 'unsafe-inline' la scripturi: cele două blocuri inline scrise de renderer poartă un tip care nu e JavaScript, deci browserul le tratează ca date și nu le execută — verificat în Chrome sub exact această politică.

Verificat pe stiva reală, nu doar în teste: nginx cu configurația din repo, build-ul real, API-ul pe alt origin. Pagina de start, o pagină de produs, formularul de comandă și o colecție, în toate cele trei limbi: zero erori în consolă, produsele și imaginile se încarcă, blocurile JSON-LD și de banner se parsează, iar un script de pe o gazdă nelistată e refuzat — deci politica chiar se aplică.

Învelișul static pe care nginx îl servește de pe disc (/admin, o adresă inexistentă, rendererul oprit) rămâne fără CSP. Nu e o regresie — așa era și înainte — dar e o lucrare separată.

3. Loginul petrece același timp pe o adresă fără cont

bcrypt.compare e singura parte lentă a unui login și era sărită când adresa nu avea cont. Răspunsul era identic — același 401, același mesaj — dar ceasul nu: 0,19-0,28 s pentru o adresă fără cont față de 0,36-0,42 s pentru una cu cont, măsurat pe producție. Asta ajunge ca să afli care adrese sunt reale.

Ambele ramuri compară acum cu un hash real de același cost: cel stocat când există rând, altfel hash-ul unui randomUUID() pe care nimeni nu-l poate trimite, calculat o singură dată la pornire. BCRYPT_COST e exportat, nu copiat, ca să nu se poată depărta.

Măsurat după schimbare, câte 20 de încercări cu parolă greșită: 68,5 ms pentru o adresă necunoscută față de 67,0 ms pentru una cunoscută, ambele 401 cu același corp. Raport 1,02, sub pragul de 1,5 cerut de issue.

Testul verifică munca făcută, nu ceasul: un test cu cronometru măsoară mașina care rulează CI-ul și pică pe una încărcată, lăsând să treacă o regresie reală pe una rapidă.

Verificare

npm run build curat, 613 teste în 42 de fișiere, toate trec (baza de referință: 600 în 39). Audit de secrete curat pe fișierele urmărite.

De reținut la fuzionare

MR-ul !79 (merged) (#26 (closed)) atinge același contract intern: rendererul cere învelișul de la /app-shell.html, nu de la /index.html. Verificate separat, ambele sunt verzi; fuzionate împreună, cinci teste din render-csp.test.ts pică, pentru că simulatorul de frontend de aici răspunde pe adresa veche. Ordinea corectă este !79 (merged) întâi, apoi acest MR rebasat pe main și re-verificat.

Closes #25 (closed)

Merge request reports