AlumDeck

Documentation · guide 9 of 10

Security review (release 2.0.0)

This is docs/09-security-review.md, one of the ten guides that ship inside the AlumDeck zip under docs/, as written for release 2.0.0. The same guide opens inside the admin, from the ? link in a screen's header or from System, Help.

This is a code-level review of AlumDeck release 2.0.0, done on 26 September 2026. It is not a penetration test. Nobody attacked a running site from outside, and no one outside the team checked the work: the reviewers were AI code reviewers (Claude), directed by Cerevonix. No external penetration test has been done. Every release is now tested in-house, and the current status of every finding below, with the later reviews and scans, is in Security tests.

How it was done

  • Three reviewers (AI code reviewers, Claude) read the whole codebase. The first took sign-in, sessions, CSRF, rate limits, headers and secrets. The second took authorisation on every route and every admin screen, plus mass assignment. The third took uploads, server-side requests (SSRF), XSS, SQL, CSV exports, redirects and the payment webhooks. Framework behaviour was checked against the code that ships in vendor/.
  • The earlier audit from 25 September (SEC-01 to SEC-21) was checked item by item against today's code.
  • Before anything was fixed, its cause was confirmed by reading the full path in the code. Two causes were also confirmed by running them: the javascript: link (C-2) and the event-gift receipt (C-1, through its regression test).
  • Every fix has a regression test. They are in tests/Feature/i18n_SecurityReviewTest.php and tests/Feature/i18n_SecurityEventGiftTest.php, and they run with the rest of the suite. The low findings closed later in 2.0.0 have theirs in tests/Feature/trust_SecurityFixesTest.php and tests/Feature/sechard_*Test.php (named with each fix below).

Severity is judged from AlumDeck's own point of view: a self-hosted site where staff hold limited admin areas (AdminAreas: view, edit or manage per area) and members sign in to a private area.

Findings

IDSeverityWhatStatus
B-1HighA staffer with the Users area could change the email and password of an account holding areas or rights they do not hold (a treasurer's refunds, sending campaigns), and so take it over.Fixed
C-1HighRaising the gift on an event registration after a payment had started kept the old payment. Once that smaller payment was paid, a numbered gift receipt was issued for the larger gift.Fixed
A-1MediumThe decrypted authenticator (TOTP) seed of an account was sent to the browser inside admin pages' Livewire data: the member page (for any Members-area staffer), the Users edit screen and your own profile.Fixed
A-2MediumStaff who ticked "Keep me signed in" on the member sign-in page got a long-lived cookie that silently reopened the admin panel.Fixed
B-2Medium"Approve selected" (registrations) had no level check, so view-only staff could create member accounts and send invitations.Fixed
B-3MediumSome moderation actions checked only the record's state, not the staffer's level: showing a reported post again, warning an author, closing a report, approving or rejecting job postings and archive submissions, hiding a forum topic.Fixed
C-2MediumPage-builder and menu links accepted javascript: addresses, so Heritage staff could plant a script that runs when an administrator follows the link.Fixed
C-3MediumA member's web-push subscription could name any https address, including the server's own network, and the scheduler then posted to it (blind SSRF).Fixed
A-4LowOn the member sign-in page, a correct password for a staff-only, pending or deactivated account started a session and then ended it. The difference was visible in the cookie, and a "signed in" line was written to the audit log.Fixed
B-8LowView-level staff who held the "send campaigns" right could reschedule or cancel a campaign send.Fixed
A-3LowAfter a password change, other sessions kept access to staff-only files and door check-in (outside the panel) until the idle timeout.Fixed in 2.0.0
A-5LowWith the password and access to the mailbox, someone could get past a TOTP account's second step through the emailed-code fallback and set it up again on their own phone, and no one was notified.Fixed in 2.0.0 (the account is emailed and the audit log records it)
A-6Low (suspected)The time the password-reset form takes to answer may reveal whether an email address has an account.Fixed in 2.0.0 (the lookup and the send follow the answer)
A-7LowEmailed links are built from the request host, and any subdomain of the site's own host is trusted.Fixed in 2.0.0 (emailed links use the configured address; only the host and its www twin are accepted)
A-8LowAnyone can force a licence check on each request with ?licence-recheck, and each one makes an outbound call that can wait up to 8 seconds.Fixed in 2.0.0 (honoured only while signing in is paused, at most once every five minutes for the whole site)
B-4LowSaved audiences with giving conditions list donors to staff who lack "see money".Fixed in 2.0.0
B-5LowA block is not enforced on contact requests, mentoring requests or group invitations.Fixed in 2.0.0
B-6LowReplying "going" to an event ignores the event's "only these memberships" rule and reveals the online joining link.Fixed in 2.0.0
B-7LowThe membership-tier gate misses the business directory and public event registration.Fixed in 2.0.0 (the business directory follows its rule; public event registration and RSVPs already did)
B-9InfoOut of date. When this review was written, the REST API routes were not registered, so tokens did nothing. They are now: bootstrap/app.php loads routes/extensions/api-routes.php, so the whole REST API is live, with writes and REST hooks. It was reviewed afterwards, in the review of 27 September 2026 (R1-09 to R1-11). Read scopes still follow the area's view level, but registration amounts now need "see money".Superseded
C-4, C-5LowThe geocoder and AI-assistant addresses (Settings) are fetched with no private-network check. Only full administrators can set them.Open, by design
C-6LowForum images are limited to 40 megapixels but have no memory guard, which is a denial-of-service risk on small hosts.Fixed in 2.0.0
C-7LowProfile photos and archive uploads are stored as sent, with EXIF data kept and no re-encoding.Fixed in 2.0.0

What each fix does

  • B-1 (app/Filament/Resources/UserResource.php, canManage()): if you are not a full administrator, you can manage an account only when its levels and rights are ones you could grant yourself. The rule is the same grantCap() / canGrantFlag() that already limits granting. Full administrators, your own account and other Users-area holders were already excluded. admin_AccessControlTest has been updated to the new rule.
  • C-1 (app/Support/EventCommerce/Registrations.php):
    • checkout() reuses a pending payment only when its amount and currency match the registration's current total. Otherwise it cancels that payment and starts a new one.
    • recordGift() records no more than the payment brought beyond the tickets. If the payment brought nothing, it records no gift and sends no receipt.
  • A-1 (app/Models/User.php): two_factor_secret is added to $hidden. The code still reads it as an attribute. The audit log already left it out.
  • A-2 and A-4 (app/Http/Controllers/Connect/AuthController.php):
    • An account with any admin access never gets a remember cookie from the member sign-in page.
    • Sign-in uses Auth::attemptWhen() with an "active member" condition. For any other account, a correct password now behaves exactly like a wrong one.
  • B-2 (RegistrationApplicationResource): the bulk approval needs the manage level. It is hidden otherwise, and the check is repeated inside the action.
  • B-3 (ContentReportResource, JobPostingResource, ArchiveSubmissionResource, BatchThreadResource): these actions need the edit level. Warning an author needs manage, the same as suspending one. Filament 5 refuses to run an action that is not visible.
  • B-8 (EmailCampaignResource::canSend()): sending needs the edit level as well as the send-campaigns right.
  • C-2 (cms_link() in app/Support/helpers.php): an address with any scheme other than http(s), mailto: or tel: becomes #.
  • C-3 (app/Support/Reach/Push.php):
    • A subscription must pass the same address checks as outgoing webhooks (SafeUrl).
    • Each send checks the address in DNS, pins the connection to the checked IP and follows no redirects. An address that fails is forgotten, as a 410 is.

The low findings closed in 2.0.0

Each cause was confirmed in the code first; each fix has the test named with it.

  • A-5 (app/Http/Controllers/Admin/TwoFactorController.php, verify()): when an account set up for an authenticator app passes two-factor with an emailed code instead, the account's address gets a security notice - the automatic email "Two-factor sign-in by emailed code instead of the app" (accounts.two_factor_email_used, changed by full administrators only) - and the audit log gets an entry of its own (twofa_email_fallback). An account whose method is the emailed code, and a sign-in with the app, send nothing. Still open as advice: see "Remaining risks" 3. Test: sechard_TwoFactorFallbackTest.
  • A-6 (app/Http/Controllers/Connect/PasswordController.php, sendLink(), and app/Filament/Pages/Auth/RequestPasswordReset.php): both reset forms answer first, in the same words for every address. Whether the address has an account is looked up, and the token made and the email sent, only after the response has gone, in the same PHP process (App\Support\AfterResponse - no queue worker). Until then the request runs the same database work for an account and for none. The form's throttle and the broker's one link a minute are unchanged. PHP-FPM and LiteSpeed release the answer before the work starts; under other server set-ups the answer has been written out in full by then, though the connection may close later. Test: sechard_ResetTimingTest.
  • A-7 (app/Support/TrustedHosts.php, app/Support/Mail/EmailLinks.php): two locks.
    • The site answers to the host in APP_URL, its www twin and the names listed in TRUSTED_HOSTS - no longer to every subdomain of its domain. A site that is also reached on another subdomain must list it.
    • Just before any email is sent, every address on the request's host is rewritten to the configured address: in the HTML part, the text part, the subject and List-Unsubscribe. Signed links are signed again, so they still open. A request on the configured host or its www twin is left as it is (a "same browser" sign-in link has to come back to the name the browser used), and so is a copy whose APP_URL is still localhost. Pages keep following the request host.
    • Not covered: a link built during a request and kept inside a notice that a queue worker sends later (only with QUEUE_CONNECTION changed from the shipped sync) keeps the request's host - which the first lock limits to the site's own names. Test: sechard_EmailLinksTest (and platform_SecurityTest for the hosts).
  • B-4 (app/Models/SavedSegment.php, SavedSegmentResource): an audience with a giving condition is listed, opened, counted and sampled only for people who may see money. For other staff it is not on the Saved audiences list, its address answers as one that is not there, the member list ignores it (?segment=), and the audience pickers of the campaign, WhatsApp, text message and event invitation screens do not offer it. A giving condition typed into a request by hand is not counted and not stored. A campaign already pointed at such an audience still sends. Test: sechard_SavedAudienceMoneyTest.
  • B-5 (ContactRequestController, MentorshipController, MentoringController, GroupLeaderController, App\Support\Groups\Memberships): with a block between two members, either way, a contact request or a mentoring request answers as it does for a member who is not listed (404), the mentor is not on the other's mentor list, and a group leader neither finds nor can invite the other. A request that was already waiting cannot be accepted across a block (accepting shares contact details); it can be declined. No answer mentions a block. Test: sechard_BlocksTest.
  • B-7 (app/Support/Dues/Access.php, features()): the gate matches a feature by route name, and the business directory's rule named routes that do not exist (connect.businesses); its pages are connect.listings, so the rule gated nothing. The name is corrected, and three other names no route has were removed. The test walks the route table: every name in a rule must match a route, every route it matches must run the member gate, and a member without the membership gets the upgrade page on all of them. Test: sechard_TierGateTest.
  • C-6 (app/Support/ForumImages.php, app/Support/Images.php): before a picture is decoded, the memory GD will need (the original and its scaled copy) is checked against PHP's memory limit by the shared helper Images::fitsMemory(). A picture that will not fit is refused by the form's validation with a sentence, where it used to end the request with a fatal error. The same check covers feed, listing, form and fundraiser images, which use the same pipeline. A sideways photo is now turned after it is scaled down, so it is no longer held twice at full size. Test: sechard_ImagesTest.
  • C-7 (Images::stripMetadata(), ProfileController, UploadController): a profile photo and an archive upload are written again with GD before they are stored - JPEG, PNG and WebP, at the same pixel size - so EXIF (position, camera, owner), comments and anything after the picture are gone. A JPEG is first turned the way its EXIF orientation says. Where only one copy fits in memory, a sideways JPEG is written as it lies and keeps a new EXIF block that holds the orientation and nothing else; where not even one fits, or the file cannot be read as a picture, the upload is refused with a sentence. Other kinds of file are left alone. Photos stored before this change, and photos staff upload in the admin panel, are not rewritten. Test: sechard_ImagesTest.

Earlier audit (25 September): where each item stands

  • Fixed and verified: SEC-01, 02, 03, 05, 06, 07, 08, 09, 10, 11, 13, 15, 16, 17, 19 and 21.
  • SEC-01 now fully fixed: B-1 above closes the part that remained.
  • Partly fixed:
    • SEC-04: the forwarded host is no longer trusted, only the configured host, its www twin and listed names are accepted, and emailed links use the configured address (A-7, fixed in 2.0.0). Links on a page follow the request host.
    • SEC-12: the whole site has one CSP, and in 2.0.0 the public site and member area allow no inline script, no eval and no inline style (a nonce per page, Alpine's CSP build). The admin keeps 'unsafe-eval' and inline styles, which Filament 5 needs (docs/10).
    • SEC-14: lockout, replay and session regeneration are fixed. The email fallback remains by design; using it now tells the account and is audited (A-5, fixed in 2.0.0).
    • SEC-18: the member area has AuthenticateSession, and the staff and two-factor routes have it too (A-3, fixed in 2.0.0).

Checked and found sound

  • Member, admin, magic-link and social sign-in: session regenerated, throttled, the same message for every failure. Magic links are hashed, bound to the device and used once. OAuth uses state, nonce and PKCE, and only verified emails.
  • Two-factor challenge: lockout per account, TOTP replay blocked, a 10-minute window, and the passed state tied to the user.
  • Admin session guard: idle and absolute timeouts, browser binding and IP audit.
  • Session cookies: encrypted, httpOnly, SameSite=Lax, secure over https.
  • CSRF: the five exemptions (unsubscribe, preferences, the OAuth callback, payment webhooks, the WhatsApp webhook) are each signed, bound to state or HMAC-verified. Every other write route has CSRF and a throttle.
  • Security headers: nosniff, frame options and frame-ancestors, HSTS over https, referrer policy, COOP/CORP, and no X-Powered-By.
  • Secrets (SMTP, gateway, social, AI, WhatsApp and VAPID keys) are encrypted at rest and never filled back into forms. API tokens are stored only as SHA-256 hashes.
  • Every public and member route checks ownership or visibility: groups, messages, feed, listings, mentoring, invoices, tickets, forms, elections, signed URLs and private files.
  • Every admin resource and page uses the area and level model. Settings pages are for full administrators only.
  • Mass assignment: no request array is passed wholesale to a model. Filament saves only the fields in its schema.
  • Uploads: stored extensions come from the file's content, never from the uploader. Import paths are confined to their own folders. Private files are served only after authorisation.
  • XSS: every {!! !!} output was traced to clean_html(), SafeMarkdown, escaped builders or JSON with the HEX flags. Translations are always escaped, so a translator cannot inject markup.
  • SQL: sort and filter columns are allow-listed. LIKE searches escape % and _.
  • CSV: every export goes through App\Support\Csv, which neutralises formulas.
  • Redirects are same-site, or to addresses issued by a payment provider.
  • Payment webhooks:
    • Stripe checks its HMAC with a timestamp tolerance, the third optional gateway its own HMAC, and PayPal uses its verify API with a certificate host check. The raw body is used and comparisons are constant-time.
    • Events are processed once, by a unique index on the provider's event id.
    • The amount and currency are matched before anything is marked paid.
    • Webhooks for unknown gateways are refused with a 404. This line used to say that disabled gateways were refused as well. That was wrong: a switched-off gateway's webhooks were still processed (the review of 27 September 2026, R1-07). The fix is in the payment webhook itself.
    • The return page never marks a payment paid.
  • Outgoing webhooks: SafeUrl (public IPs only, DNS pinning, no redirects, timeouts).
  • dompdf runs with remote resources and PHP off.

Remaining risks and advice

  1. CSP in the admin. Done for the public site and member area in 2.0.0. The admin panel still allows eval and inline styles (Filament 5's own templates need them); inline scripts there also need the page's nonce.
  2. A-3. Done in 2.0.0: the staff and two-factor route groups have AuthenticateSession (tests/Feature/trust_SecurityFixesTest.php).
  3. A-5. Done in 2.0.0 for the fallback: the account is emailed when the emailed code is used in place of the app. Still advised: email the account when the method changes too, and when TOTP is already set up, require the TOTP challenge before the method can be changed.
  4. A-7. Done in 2.0.0: emailed links are pinned to APP_URL, and only the exact host, its www form and the names in TRUSTED_HOSTS are accepted.
  5. A-8. Done in 2.0.0: licence-recheck counts only on the lock screen, once every five minutes per site.
  6. The low authorisation items (B-4 to B-7). Done in 2.0.0: see "The low findings closed in 2.0.0" (B-6 has its test in tests/Feature/trust_SecurityFixesTest.php).
  7. An external penetration test is not planned; the in-house tests are published instead. Anyone who commissions one should start with these three:
    • the payment flows against real provider sandboxes;
    • the admin panel as area-limited staff;
    • uploads on the target host's web server configuration, since .htaccess is the last line of defence for the media folder.
  8. Hosting. Keep APP_DEBUG=false, keep the project root outside the web root, and keep the scheduler running. The installer writes APP_DEBUG=false, the install guide's layout keeps the project root outside the web root, and the dashboard says when the scheduler stops. On Nginx or LiteSpeed, add the lines in docs/server/; Check my headers (Settings > Security) shows what reaches a browser.

Stuck on something this guide does not cover?

Write to the people who build AlumDeck through the contact form. We aim to reply within one working day, Monday to Friday: a target, not a guarantee.

Type a word, or start with one of these pages.

↑ ↓ moveEnter openEsc close