Skip to content

Security ​

Security headers, a Content Security Policy built from the browsers' reports, a journal of sign-ins and locks on addresses that guess passwords.

kitloom/security · admins: kitloom/security-filament, kitloom/security-nova

Video lessons ​

Recorded in the Filament admin; the Nova admin works the same way.

Security headers, a Content Security Policy, a journal of sign-ins and locks on addresses that guess passwords. A header is a line the server sends with a page, telling the browser what it may do with it. They close whole kinds of attack: another site showing yours in a frame to trick a visitor into clicking, an upload passing for a script, a script that got into a page sending your visitors' passwords elsewhere.

Installing the module is enough. It starts with what cannot break a site, and everything else waits on Security → Settings, which also shows what an answer carries now. Run php artisan migrate for the tables of the journal, the locks and the policy's reports. The admin section is kitloom/security-filament or kitloom/security-nova.

The headers ​

Security headers in Laravel: HSTS and X-Frame-Options
HeaderFrom the startWhat it does
Strict-Transport-SecurityOffBrowsers that have seen the site keep to HTTPS
X-Content-Type-OptionsnosniffBrowsers take a file for what the server says it is
X-Frame-OptionsSAMEORIGINOnly this site may show its pages in a frame
Permissions-PolicyCamera, microphone and location closed; payments for the site itselfBrowser features the site does not use stay closed to any script on it
Cross-Origin-Opener-PolicyOffA window the site opened cannot reach back into it
Referrer-Policystrict-origin-when-cross-originSet by the site itself (kitloom-site.referrer_policy), shown here for the full picture

An answer that sets one of these itself keeps its own: the consent page of an AI agent's sign-in says DENY whatever you choose here.

Before you turn on HTTPS only ​

Strict-Transport-Security is the one header here that can hurt. A browser that has seen it refuses plain HTTP to the site until the time runs out, and nothing you do on the server takes that back. So, in this order:

  1. Make sure the whole site works over HTTPS, with a certificate that renews itself.
  2. Turn it on for 7 days, and use the site for those days.
  3. Raise it to 365 days.
  4. Turn on Include subdomains only if every subdomain works over HTTPS, also the ones this site does not serve, such as a mail or a staging address.
  5. Turn on Allow preloading only if you mean to submit the site to browsers' list. It goes out with subdomains and at least 365 days, and leaving that list takes months.

The header goes out only over HTTPS. If the settings page says it was held back though the site is on HTTPS, a proxy or a load balancer stands in front of the site and Laravel does not trust it yet: see Configuring trusted proxies.

Frames ​

This site only suits nearly every site: the Customize screen shows the page in a frame of the site itself. Nobody also stops Customize from showing the page. Choose Anyone if another site of yours has to frame this one.

Browser features ​

The text is written as the header is, for example camera=(), geolocation=(self), payment=(self "https://pay.example.com"). Left empty, it is kitloom-security.permissions_policy. Open a feature here if a theme or a script of yours needs it, such as geolocation=(self) for a "near me" search.

Opener policy ​

Off from the start, because a sign-in or a payment that opens a window of another site may stop working under it. Try Same site, windows it opens stay connected with your own sign-in and checkout before you keep it.

Content Security Policy ​

Content Security Policy in Laravel, from report-only to enforce

The policy says what a page may load and run: scripts only from this site or with the page's own secret mark (a nonce), and from the hosts your modules name. A script that gets into a page by a hole in a plugin or a stolen password then simply does not run, and cannot send anything away. It is the strongest header here, and the one that can stop something your site needs, so it comes in three steps on Security → Settings:

  1. Off from the start. A page stays exactly as it was.
  2. Report only. Nothing is stopped. Browsers tell the site what the policy would have stopped, and Security → Policy Reports lists it: what, under which directive, on which page, how many times. Use the site for a week, and go through your checkout, sign-in and forms yourself: a payment page you are sent to shows up here under form-action.
  3. Enforce. What the policy does not allow is stopped.

On the reports screen, Allow puts a host on the policy's list (Also allow on the settings page) and clears the reports it answers. Allow only what you recognise: a report of a host you do not know may be just what the policy is for. Dismiss deletes a report. Browser extensions report too; those are dropped before they reach the list.

What is already allowed ​

WhatHow
Your site's own files, and the host of ASSET_URLAlways
Pictures and video from anywherePosts link them from everywhere, and they run nothing
Inline stylesThe block editor's markup and a server-drawn page are full of them
Frames from YouTube, Vimeo, X, Instagram, TikTok, Facebook, Spotify, SoundCloud and Google Mapskitloom-security.csp.embeds
The counters of Analytics, the pixels of Ad Pixels, Tag Manager's container, Sentry, the captcha of Forms, a theme's fontsEach module names its hosts, only for what is switched on

Only the admin and the dashboards (Telescope, Horizon, Pulse) go without a policy: Filament and Nova need code a nonce does not reach. The other headers go out there too.

Scripts written into your content ​

A <script> or an onclick= written into a post's text is stopped when enforcing, and shows on the reports screen as inline with the page. A host cannot be allowed for it: move the script into a snippet (Header & Footer, whose scripts get the nonce) or a theme. If that has to wait, Allow inline scripts lets them run, and gives up most of what the policy protects.

Tag Manager ​

The module allows the container's own host. What the tags inside it load is known only to the container, so those hosts come through the reports. A custom HTML tag runs only if you pass the nonce to it, as Google's guide for a nonce-aware container describes.

Old http:// pictures ​

While enforcing over HTTPS, Upgrade insecure requests has browsers load the http:// pictures and files of old posts over HTTPS, which also removes the mixed content warnings of a site moved from WordPress.

Sign-ins ​

Sign-in attempts in Laravel: lock the addresses that guess passwords

Security → Sign-in Attempts lists every try on the site's form and on the admin's: when, how it ended, with which login, from which address, in which form. Never a password. Lock this address on a line locks it.

The login forms already slow down guessing at one login from one address, and the module leaves that as it is. It adds what those cannot see: one address guessing across many logins and every form. By default:

  • 5 wrong passwords in 15 minutes from an address lock it for 20 minutes.
  • An address locked 4 times in a day waits 24 hours.
  • Signing in with the right password starts its count anew, and so does waiting out a lock.

A locked address gets the same answer whether its password was right or not, so a lock tells a guesser nothing. Someone already signed in stays signed in. Security → Locked Addresses shows what is in force, lifts a lock, and locks an address or a whole network (203.0.113.0/24) by hand, for a time or for good. On the settings page, Never lock is for your office, and Always refuse for addresses that may never sign in. Both take addresses and networks.

If you lock yourself out ​

From the server, one command lets an address back in:

bash
php artisan security:unlock 203.0.113.5

Behind a proxy ​

The settings page says which address the site sees your request coming from. If it is not yours, a proxy or a load balancer stands in front of the site and Laravel does not trust it yet: every visitor looks like one address, and one person's guessing would lock everybody out. Set up trusted proxies first. The site's own machine is never locked, so the plainest such case cannot lock everyone out.

What it does not cover ​

The REST API's application passwords keep their own limit (kitloom-api.wrong_passwords) and are not in the journal. The two-factor step keeps its own limit per user.

What is kept, and for how long ​

Attempts are kept 30 days and reports 14, and the rest goes every night (security:prune); both are settings. An attempt holds an address and a login, which may be someone's email, so it counts as personal data: kitloom/privacy exports and erases a person's lines. A lock names an address and no person, and stays until it ends.

The Activity Log goes on listing failed sign-ins as its audit trail, and Notifications' user.locked_out tells of one login's throttle. This module adds security.address_locked for a locked address, on by default.

For AI agents ​

With kitloom/ai, an agent has the tools security_report, security_login_attempts, security_locks, security_lock_address, security_unlock_address, security_csp_reports, security_csp_allow and security_settings_update, all behind manage_security, and is told the policy's way first: report, allow what the site uses, then enforce. It is told to ask you before HSTS and before enforcing, and to ask about any host in a report it cannot tie to something the site uses, since a report may name an attacker's host.

With the page cache ​

A page the page cache keeps has its headers with it, and they go out with the page, also when the page is served without starting the application. Its nonce goes with it, so markup and policy always agree; it changes when the page is drawn again, which any save of the page does. Saving an option here draws all those pages anew. Pages kept before the module was turned on have none until they are drawn again, so press Clear cache on Settings → Cache after turning it on.

For a developer ​

The options are security_*, written through SettingWriter with the manage_security permission (administrators). A value is checked once more when the header is made, so a row edited by hand never reaches a header. The configuration's filament.register and nova.register leave the section out of that admin; the headers go on being sent. Nothing is kept between requests, and each request gets its own nonce, so Octane needs nothing.

The policy is built on spatie/laravel-csp's Policy. A module says what it loads with a Spatie\Csp\Preset tagged Kitloom\Security\Csp\CspPresets::TAG, behind class_exists(CspPresets::class) and with kitloom/security in its suggest, and answers for what is switched on. An inline script of a module carries Vite::cspNonce(). An answer that sets its own Content-Security-Policy keeps it. Browsers report to POST /wp-json/security/v1/csp-reports: through report-uri, and through the Reporting API over HTTPS. Reports are capped per address and in the table.

kitloom — modules and themes for Laravel.
Contact · Pricing · Services · License · Refunds · Privacy · Community · YouTube · [email protected]