A non-profit website had a key in a public JavaScript file. The key opened the donor ledger.
The file was the script the browser downloads to render a public page. It was not a secret. With that key, the content API returned the media library, including hundreds of filled tax-declaration PDFs, the payment records, volunteer registrations with dates of birth, the user collection, and the shape of the whole content model. The same key could upload files into the CMS.
That was one of two critical findings. The other was quieter, and just as complete. An endpoint minted a valid session from an email address alone. No password.
What “critical” meant here
Both critical issues were reachable without an account on the public site.
The first was a privileged CMS token compiled into frontend code. Anonymous requests to the sensitive collections were refused. The same requests, carrying the token from the script, were not refused. Read access was enough to expose tax forms and donor history. Write access meant new files could be placed in the media library, on an origin where staff later open those files.
The second was session issuance. Hand the endpoint an email address that belonged to an account, and it set a session cookie. The caller then had that account.
One is a secret stored in the wrong tier. The other is an authentication check that accepts a username as if it were proof.
A scanner that looks for missing headers will not lead with either of these. Both are logic.
The high findings continued the same failure
Four high findings sat next to the critical pair. Personal data was not confined to that one token.
- A public CMS role exposed hundreds of national ID numbers on signed tax declarations, along with other registrant and buyer details, and it accepted new records from callers who had not logged in.
- Password hashes and donor details were readable through fundraising pages, again without a session.
- Payment status on an order could be updated by an unauthenticated request.
Read beside the critical token, these findings match it. Tax forms, donors, payments, and users were the private records, and several paths reached them without checking who the caller was.
The other 16 findings
Fourteen medium findings, one low, one informational. None of them need a conference talk. Together they are how a small site loses a week.
- Login had no rate limit and no lockout, and the timing of the login and password-reset responses differed for addresses that existed.
- Order creation trusted delivery cost and totals sent by the browser, including a total of zero, and stock could be reserved before anyone paid.
- A payment link could be created for an amount the server had not checked.
- Complaint submissions could set status, priority, and the official answer on internal workflow fields.
- Tax-form handling wrote a permanent public PDF even when validation failed.
- A file upload into the CMS did not require a user.
- After login, a return address was taken from the query string and not restricted.
- The session cookie was readable by script, and a token already issued still worked after logout and after a password reset.
- The bare domain answered on plain HTTP and sent no HSTS.
- The frontend framework was behind on published advisories.
- A provisioning API for an attached learning site was exposed and failed on valid calls.
- One informational note described a caching quirk in the edge bot challenge. It did not, by itself, expose data.
A recurring-donation control failed in a way donors feel directly. Cancel succeeded immediately at the payment provider, and reactivate returned an error. That one is a product bug as much as a security bug. It still belonged in the report, because the button does the opposite of what we think, and that is how integrity bugs hide.
What we told them to change
The report pairs each finding with a change. The pattern across the serious ones is five changes.
Keep privileged keys on the server
The browser should call your origin. Your origin should call the CMS with a token the visitor never sees. If a token must exist for a public read, it should not be able to list users, list the media library, read payment records, or upload.
Do not create a session from an identifier
Email, phone, and account id are names. A session starts from a secret, or from a completed second factor.
Decide money on the server
The amount, the delivery cost, and the fact of payment come from your order record and from the payment provider's confirmation. A request body is not a receipt.
Treat tax forms and donor records as private
The role used by anonymous traffic does not get list or create on those collections. PDFs of a filled declaration are not public files with a long name.
Close the ordinary gaps in the same pass
Rate-limit login. Set the session cookie so script cannot read it. Invalidate outstanding tokens on logout and on password reset. Send HSTS. Run a framework version that includes the current fixes.
None of that is a new control framework. It is the difference between a CMS used as a database the public can reach, and a CMS used as a private store with a thin public API in front of it.
What this does not prove
The useful part is the pattern, and the pattern is common on any site that takes donations, stores a tax form, and renders the frontend from a CMS token because that was the fastest way to ship.