Security and privacy
The rules in this chapter are not defence in depth for its own sake: each one closes something a real installation would otherwise get wrong. Read it before touching uploads, headers or anything that stores a person’s data.
Security headers
Section titled “Security headers”Hilms\Access\Http\Middleware\SecurityHeaders writes the five headers a content security policy does not cover, on every response, redirects and error pages included:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin- a
Permissions-Policyclosing camera, microphone, geolocation and cohorts - HSTS on a secure request
They are ours rather than a package’s because a second package would have brought a second CSP implementation. An exception unwinds the middleware pipeline, so bootstrap/app.php puts the same five — SecurityHeaders::apply() is their one definition — on whatever the exception handler renders, which is how a guest’s /admin or /account redirect still carries them. The policy itself stays on the middleware: a redirect has no document for it to govern.
Content security policy
Section titled “Content security policy”spatie/laravel-csp 3, with presets in Hilms\Access\Csp:
| Preset | Governs | Shape |
|---|---|---|
FrontendPreset |
The student front-end | Strict: default-src 'self', no unsafe-eval, a nonce on every script and style, object-src and frame-ancestors none |
PanelPreset |
/admin, /horizon, /pulse, /telescope |
Relaxed on purpose: unsafe-inline and unsafe-eval, because Filament ships no nonce support and needs the full Alpine |
The front-end’s one concession is style-src-attr 'unsafe-inline', for the style= attributes the rich-text renderer and the progress bar write, which no nonce can cover. Its frame-src is built from config('blocks.embed_hosts') plus the three video players, and the panel reuses exactly that list, because the block editor previews a video or an embed by rendering the real iframe.
Media in a bucket is named, not allowed wholesale. Hilms\Access\Csp\MediaOrigins works the bucket origins out from configuration the way the S3 client addresses a bucket — a sub-domain of the endpoint (or of Amazon’s regional host) unless path-style addressing is on or the name cannot be one label — plus the public disk’s url and temporary_url, and a host that is not a plain host name is dropped rather than let end the source list. The front-end policy puts them in img-src and media-src, because a lesson file on a bucket is answered with a redirect to the bucket and public media is read from its public address, and the origins of the public media disk alone in font-src, because a style’s fonts are public media (Styles); the panel’s adds connect-src, where the upload field fetch()es a stored file to preview it, and allows blob: for the preview of a file just chosen. Both bucket disks are always asked, because a file stays in a bucket until it is moved back.
IssueCspNonce mints the request’s nonce before anything renders: spatie builds the policy from the finished response, so a document that asks Vite for a nonce while it renders — the font stylesheet does — would otherwise find none and then be refused by the policy written a moment later. ViteNonce hands the same nonce to Vite and, through Vite::cspNonce(), to Livewire.
UseLivewireCspBundle switches Livewire to its CSP-safe bundle for every request outside the panel and the Livewire endpoint prefix. The two builds need URLs of their own for that to hold, which is why hilms:install and hilms:upgrade publish Livewire’s assets into public/vendor/livewire.
HotOrigins adds the origin in public/hot and in every themes/*/public/hot while a Vite dev server runs, so the policy is on in development too. CSP_ENABLED turns it off; CSP_REPORT_ONLY moves the front-end policy into report-only. The panel policy is always enforcing.
A route that needs one thing widened extends a preset in its own module, because a preset that reads a model may not sit below it:
Hilms\Theme\Csp\CataloguePresetfor/design;Hilms\Api\Csp\AuthorizePresetforoauth/authorize, which appends the agent’s registered callback origin toform-action— a browser checks that directive against the redirect a form submission is answered with, not only the action it posts to.
Anything inline on the front-end needs a nonce, so a handler goes into resources/js/*.js and never into an attribute. That is why the impersonation banner is the theme’s own partial: the package’s writes inline styles.
Uploads
Section titled “Uploads”Nothing a user sends is trusted: not the declared type, not the file name, not the size the browser reports. One field enforces all of it.
App\Support\UploadLimits reports the effective limit as the smallest of PHP’s upload_max_filesize and post_max_size, Livewire’s temporary-upload rule and the media library’s max_file_size. One setting moves the last two: UPLOAD_MAX_KILOBYTES (100 MB in production, 10 MB locally). Raising the limit means raising all of them — in the Docker image PHP_UPLOAD_MAX_FILE_SIZE, PHP_POST_MAX_SIZE and NGINX_CLIENT_MAX_BODY_SIZE sit beside it — and a system limit is never raised to make a symptom go away.
App\Filament\Components\MediaUpload is the only upload field in the panel. It:
- caps whatever
maxSize()states at the effective limit, and shows that limit as helper text, so FilePond refuses an oversize file in the browser; - attaches a
FileContentTyperule to whateveracceptedFileTypes()declares, so the bytes are sniffed rather than the browser believed (and an image must decode); - turns
FileIsTooBigandFileCannotBeAddedinto field errors instead of exceptions; - asks the record which disk it belongs on;
- adds the file to the media library from Livewire’s temporary file rather than from its contents, so a video is streamed onto its disk — a bucket included — and never held in memory.
Video and audio additionally pass App\Rules\PlayableVideo / PlayableAudio, which read the file with getID3. Laravel’s mimetypes and image rules alone are not enough for a Livewire upload: they trust the browser’s declared type. Livewire’s temporary uploads stay on the local disk whatever storage is chosen, because every one of these rules reads a local file.
Each field states its own intended maximum and is capped to the ceiling. A library file’s is its kind’s — pictures 8 MB, documents 50 MB, recordings 100 MB, video 200 MB (AssetKind::maxKilobytes()) — and Hilms\Library\Rules\AssetFile judges the file by the kind its bytes are: a name whose extension claims another kind is refused, so lecture.mp4 holding text never becomes a document called a video.
An SVG is a document on our own origin, so what is stored is never what arrived. MediaUpload::svg() is the one door: it writes the output of enshrined/svg-sanitize (App\Support\Svg, remote references removed) over the temporary file before the media library sees it, and refuses a file it cannot write back clean. A wildcard never reaches an SVG — MediaUpload::image() keeps the field’s own list rather than letting Filament replace it with image/*, and puts a list given as a closure back unevaluated — so the type is accepted only where it is named outright, and naming it is what turns the sanitiser on, whichever way it got onto the list. There is no order of calls that stores one unsanitised. App\Rules\SanitizedSvg sits on every upload field and refuses a file called .svg holding something else; finfo calls a document with no XML declaration plain text, so a file counts as an SVG only when the sanitiser gets an <svg> root out of it. The branding logo is where it is offered.
From a web address
Section titled “From a web address”“Add from a web address” makes this server download whatever address an editor typed, and that is treated as the threat it is. App\Support\RemoteFiles\RemoteFile is the fetcher:
httpsonly, on port 443, with no credentials and no whitespace, backslash or control character in the address; a name in its ASCII form, never one whose last label is a number in disguise (2130706433,0x7f.1);- every A and AAAA answer (
HostResolver) must be public (PublicAddress: PHP’s private and reserved flags plus a written-out list — CGNAT, link-local and the metadata address among it, multicast, documentation and benchmarking ranges, every IPv6 form carrying an IPv4 address, unique-local and link-local IPv6 — with NAT64’s well-known prefix judged by the IPv4 address it names); - the connection is pinned to the address that was checked (Symfony HttpClient’s
resolve, so TLS still verifies the name), under Symfony’sNoPrivateNetworkHttpClientas a second guard on the address the connection actually reached; - no proxy; redirects followed by hand at most three times, with every check made again;
- a declared
Content-Lengthover the field’s own limit is refused before the body is read, and the body is counted as it arrives; the whole takes at most 40 seconds (10 to connect, 15 of silence), inside the web server’s sixty.
fetch() also takes the list of the only hosts a file may come from, checked on the first address and on every redirect alike. The Fonts page names fonts.bunny.net alone for everything it asks the catalogue for — the list, a family’s stylesheet and each face whose address that stylesheet gave — so a stylesheet pointing a face anywhere else, or a redirect leaving the catalogue, is refused; every face is then sniffed as font/woff2 before it is stored, under a name HiLMS builds, and every value of it that reaches a stylesheet is held to an allowlist (Styles). A visitor is only ever served our copy.
The download lands in Livewire’s temporary storage exactly as an upload would, so every rule above judges it; the field’s rules are also asked the moment it lands, so a page that is not a video is refused in the window it was asked for in and nothing is kept. A refusal is a translated sentence under the address field and a line in the log at info naming the host alone — nothing the remote server said is ever shown. The action authorises like the form it sits in and is throttled by the media-fetch limiter: twenty a minute per person, enough for an editor filling a gallery and too few to use the server as a scanner.
Every other user input is validated with explicit rules and allowlists, and the same applies to anything a language model or an agent returns (see Blocks). An address an editor types into a menu item or a hero button goes straight into an href, so it passes Hilms\Navigation\Rules\LinkTarget — at the form, at the service and again as the menu snapshot is built (Navigation).
Course material
Section titled “Course material”A file of the media library is public or course material, and its disk follows: public media on media-library.disk_name, course material on media-library.lessons_disk (storage/app/private/lessons, or the private lesson bucket), never under public/. A page and a course cover can show only public files; only a lesson may show course material, because only a lesson decides access for itself. Media is the whole walk.
Hilms\Library\Media\PrivateUrlGenerator is the media library’s URL generator: a file on a disk whose visibility is not public gets a signed media.show route naming no lesson wherever it lives — and never the bucket’s own presigned address, which would skip the check below. That link is the panel’s, and opens only for somebody who may view the asset in the library. What a student is given is minted by Hilms\Library\Support\AssetUrl for the lesson that shows the file: a 60-minute signed route carrying lesson inside the signature.
GET /media/{media}/{conversion?} (throttle:media, signed) is handled by Hilms\Learning\Http\Controllers\MediaController — in learning, because only that module may decide whether a lesson is open. It answers media owned by a library asset and nothing else, and for a link naming a lesson it refuses unless that lesson shows the asset (a media_asset_uses row) and ResolveLessonAccess opens the lesson for the signed-in visitor, so a live link passed to somebody else is worth nothing and a file a lesson stopped showing stops opening through it. ResolveLessonAccess opens nothing of a course that is not published — a draft, an archived course, one scheduled for later — to anyone but those who may preview it, whatever a seat or a free preview says: the player 404s such a course first, but this route asks the resolver alone, so until the security review a free-preview lesson’s files stayed open to a guest, and a seat’s to a former student, for as long as a link signed while the course was public lived.
A recording answers only its player. For video and audio, a request whose Sec-Fetch-Dest is present and is not video, audio, track or image is refused with 403 before a bucket is ever named: an address bar, a frame, a script’s fetch(), a download manager the browser hands the link to. The header is the browser’s own and no page can set it; a client that sends none is not a browser and is let through; whoever may update the lesson is exempt, because the panel’s upload field fetches the file to show it. Pictures and attachments are not asked: a gallery opens its pictures in a tab, and a file is there to be downloaded.
Then it answers: a file on a local disk with a BinaryFileResponse (byte ranges included, which is what scrubbing a video needs), Cache-Control: private and Vary: Sec-Fetch-Dest, so the copy a player was given is not handed to an address bar from the browser’s cache — or, with MEDIA_SENDFILE=nginx and only for the lessons disk, an X-Accel-Redirect to the internal /_lessons/ location. A file in a bucket gets a 302 to a presigned address that lives five minutes and carries the type and the disposition, so the bucket serves the bytes. Content-Disposition is inline for images, video and audio and attachment for files, built by HeaderUtils::makeDisposition() with the real name percent-encoded beside an ASCII fallback.
The media rate limiter allows 600 requests a minute per person: a lesson opens every file it shows at once and a player asks again with a byte range on every seek, so ten a second sustained for a minute is beyond anybody watching and not beyond a script.
The players carry controlsList="nodownload" and disablePictureInPicture, so the browser offers no download button. An uploaded video whose files live in a bucket also carries crossorigin="anonymous": a caption track from another origin without CORS is thrown away, and CORS mode still sends the session cookie to media.show, which is on this origin. The bucket then needs a CORS rule for APP_URL, which the settings page’s guide hands out and checks. The five-minute address is not lengthened to suit a long lesson: a player whose address is about to run out asks media.show again, which checks access before it signs a new one (Media).
The watermark
Section titled “The watermark”“Watermark uploaded videos” on the “Media and storage” page (blocks.watermark_videos, off by default) marks every uploaded video with the signed-in viewer’s e-mail address, drifting across the frame, so a screen recording names whoever made it. Never the editor’s preview, never a guest. Because the mark has to survive full screen, the frame — video and mark together — goes full screen through a button of its own, and a marked player loses the browser’s own full-screen control and its context menu, where “Save video as” and “Copy video address” live. An unmarked player keeps the context menu: it also carries Firefox’s playback speed, and an installation that does not ask for protection should not lose it. Theming has the markup a theme keeps.
What this achieves: a lesson file cannot be fetched without a session the lesson is open to, a copied link dies within the hour and opens nothing in an address bar, the player shows no download control, the file never sits under public/storage, and, when the installation asks, a screen recording names whoever made it. What it does not: a viewer with developer tools can still save the stream their own player receives (a client that sends no Sec-Fetch-Dest is let through, and the mark is a layer anyone can delete), an iPhone’s native full screen shows the video without its mark, and nothing stops a screen recording.
For video where that matters, the Video block offers Bunny Stream, whose embed is minted per view with a one-hour token derived from the token key on the “Media and storage” page (or BUNNY_STREAM_TOKEN_KEY), so a saved page cannot replay it; the block’s help text says to turn token authentication on and set the allowed referrers. A Vimeo private link keeps its ?h= hash, so an unlisted video plays. DRM is a paid host feature HiLMS does not build.
Secrets on a settings page
Section titled “Secrets on a settings page”The Mail, AI assistants and Media and storage sections of Settings hold secrets: an SMTP password or an API key, the provider key, the bucket’s secret access key and the Bunny token key. Each is encrypted with APP_KEY through spatie’s encrypted() and is a SecretInput: never rendered back, a blank field keeps what is stored, typing replaces it and a companion toggle removes it. Every SettingsSection carries KeepsStoredSecrets, which reads the list from the settings class’s own encrypted(), so a new secret is protected the moment it is listed there (Settings). A SecretInput carries autocomplete="new-password": Chrome ignores off on a password field and would fill in the administrator’s own panel login, which Save would then store as the secret. KeepsStoredSecrets also empties every secret field once the save has stored it, or the save’s own response would carry what the administrator just typed back to the browser in the component’s state. A bucket that refuses a connection is reported as StorageUnreachable, whose message has every configured secret replaced by [secret].
Authorisation
Section titled “Authorisation”- The panel runs with
strictAuthorization(): a missing policy method denies rather than allows, and says so out loud in development. adminis the super admin throughGate::beforeand holds no permissions of its own.- Every custom Filament action carries its own
->authorize(), because Filament policy-maps resources and never actions. The full list of which action needs which permission is inSPECS.md§6; the rule behind it is that a control doing for somebody what they could do for themselves at/accountasks the user policy’supdateabout that person, and an assistant needsCreate:Generationand the subject’s ownupdate. - Ask the policy with the record, not a bare permission, wherever the record matters.
->authorize('Update:User')checks a permission and nothing else;->authorize('update')hands Filament’s record to the policy. The difference is how a role holdingUpdate:Usercould take over an administrator — change the address and the password, then sign in — untilUserPolicy::update()anddelete()began refusing anyone but an administrator on an administrator. Shield’s super admin passes every policy, so a guard an administrator must meet too lives in the resource’scanEdit()/canDelete()instead. - Permissions are seeded per entity with the actions that entity really has, and everything else is pruned.
tests/Feature/PermissionsTest.phpcompares the seeded names with the strings in the source in both directions. - Nobody but the admin holds the ApiClient permissions, the Activity permissions or the monitoring abilities. The monitoring abilities are listed as Shield custom permissions, so the Roles UI can still grant them to someone else.
- What a role reaches is narrowed by the record, not only by the permission. An instructor reads the courses they teach and the seats, grants and quiz attempts of no other (Catalog); only an administrator acts on an administrator (Access); only an administrator changes roles or opens the Roles page; a course somebody was ever granted is never purged, and purging at all is an administrator’s alone. These came from the owner’s permissions scan of October 2026, which also made sure no toggling of roles can lock an installation out of itself.
- Roles and permissions always use the
webguard;User::$guard_namepins it, because a request authenticated through another guard would otherwise look for roles that do not exist there. /healthanswers 403 whileHEALTH_SECRET_TOKENis empty, and Telescope authorises through its gate in every environment.
What the pages give away
Section titled “What the pages give away”Nothing tells a stranger whether an address has an account: the forgot-password form answers every address alike and lands on a page of its own, the reset form refuses an unknown address in the words it uses for a bad link, login answers one message for every failure, and the passkey sign-in names nobody (Access). Registration is the one exception, by the owner’s choice, behind its limit of ten an hour. Every route that checks a password, a code or a seat has a named limiter, and each refusal says when to try again.
The security review of October 2026 swept every route’s middleware, every Filament action’s authorisation, every unescaped echo (rich text goes through Filament’s renderer, which strips scripts, handlers and javascript: links), raw SQL and mass assignment, then had an independent reviewer read authorisation, uploads, SSRF, signed links, sessions, secrets, the API, the MCP tools and the OAuth registration. What it kept, deliberately: addresses an administrator types — a bucket endpoint, a mail server, an AI provider’s base address — may point at a private network, because a self-hosted bucket or relay is a legitimate choice, while RemoteFile, the one door an editor reaches, refuses private addresses at every hop; agent registration is open, because an agent has no credentials before it registers, so the consent page, naming the host the code goes to, is the control; the pager’s “previous” and “next” are printed unescaped, because the phrases carry « and » and only an administrator can override a phrase.
Personal data
Section titled “Personal data”Hilms\Access\Contracts\PersonalDataSource is how a module contributes one file to an export without access importing it. User::selectPersonalData() writes one JSON file per source plus a README.txt. Archives live on the private personal-data-exports disk, are cleaned nightly after PERSONAL_DATA_EXPORT_DELETE_AFTER_DAYS days, and are served only to the signed-in owner.
Adding a module that stores something about a person means adding a source and registering it in that module’s provider. Forgetting to is the failure mode this design exists to make obvious.
A file somebody uploaded to the media library is staff content, not personal data: it is not in the export, and deleting the account leaves the asset in the library with its uploader pointing at the tombstone.
Account deletion
Section titled “Account deletion”Only Hilms\Access\Actions\DeleteAccount deletes an account, and it leaves a tombstone rather than removing the row, because every foreign key to users cascades and a hard delete would take the entitlement trail with it. The full behaviour is in Access; the parts that matter for a security review:
- it refuses the last administrator itself, so no caller can bypass that;
- it takes every OAuth token the account held, so an agent approved by that person acts as nobody;
- entries about the account go, entries it caused stay;
- a tombstone cannot sign in, and its former address can be registered again.
Impersonation and its audit caveat
Section titled “Impersonation and its audit caveat”Impersonation is offered to administrators alone, refuses another administrator and every tombstone, and draws a banner for as long as it lasts.
Whatever is done while impersonating is recorded as the person being impersonated, in the audit trail and everywhere else, because the session is theirs. Nothing in the trail distinguishes it from something that person did themselves. That is the price of seeing exactly what they see, it is stated in the panel before the click, and it is why the ability is not delegated.
Retention
Section titled “Retention”docs/install/retention.md is the written policy and the place to update when a new store appears: every store, what it holds, how long it stays, which environment key moves it, and what a deletion reaches.
Three limits are stated there rather than glossed over, and they belong in any answer to “can you erase me completely?”:
- Backups taken before a deletion hold the old data until the last one is cleaned up — at most about seven months under the default retention, which deliberately keeps no yearly copies.
- Anything exported by hand — a dump taken for a migration, a copy on a laptop — is outside the application entirely.
- Mail already delivered cannot be recalled, and the provider behind the chosen transport keeps its own log for its own period.
MAIL_MAILER=log writes message bodies into storage/logs, where nobody expects personal data. It is never right for an installation real people use, and MailTransportCheck fails on it in production.
What an assistant is sent leaves the installation, so it is listed rather than assumed: a lesson draft sends the lesson title and the editor’s notes, a quiz sends that lesson’s prose, a summary or a description sends the course outline. No student ever appears in a prompt, nothing is sent unless an editor asked for it, and the whole course is never sent. The prompt and the answer are kept on the row for keep_days days and then pruned, and the provider key is stored encrypted under APP_KEY and never rendered back into the form, so reading it needs the same access as reading .env.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.