Abdulkader Safi

Reading my own release checklist found two bugs I had written down and walked past

The gate list for 1.0.0 had eight items under "bugs the audit turned up". Two of them were not tidying: the home page had two URLs with the canonical naming the copy, and any URL typed in the panel went into an href without a scheme check. Both were an hour, and both were about to be frozen behind a version number.

4 min read

Share
A release checklist with two items ticked and the rest left open with reasons.

1.0.0 was going to be a small release. Drag to reorder had landed, the editor could move between pages, the API needed naming. Tag it.

Before tagging I reread Docs/tasks/14-v1-release.md, the file that lists what has to be true before the number changes. Under "the bugs the audit turned up", written on 3 September and not looked at since, two entries stopped me.

One page, two URLs, canonical on the wrong one

The home page is the page whose slug is home, and the controller serves it at /. Fine.

Page::url() had no case for it:

return $locale === array_key_first(config('atelier.locales'))
    ? url($slug)          // "home" -> /home
    : url("{$locale}/{$slug}");

So the canonical tag said /home. The hreflang alternates said /home. The sitemap entry said /home. Every menu item a client built by picking the home page said /home. Meanwhile /home served the same content, through the catch-all, with a 200.

Duplicate content, with the canonical pointing at the duplicate, on a package whose main argument is that it is better for SEO than a client-side page builder.

The fix is four lines in url() plus a 301 from the named URL to the root. What made it worth the interruption is that the wrong URLs were being written into menus and sitemaps as data, so shipping 1.0.0 first would have meant every site built on it accumulating them.

Two existing tests changed, which is the honest signal that behaviour moved rather than a typo being corrected.

Every block that renders a client-supplied URL did this:

<a href="{{ $attributes['cta_url'] ?? '#' }}">

Blade escapes the value, so it cannot break out of the attribute. It does nothing at all about the scheme. Type javascript:alert(document.cookie) into a call to action's button link and every visitor to that page runs it.

The threat model is narrower than it sounds, since it needs panel access, and the person with panel access owns the site. It is still stored XSS: a client pastes a tracking link somebody emailed them, or an agency hands the panel to a junior, or the panel has ten users and one of them is annoyed. And the mitigation is an allowlist:

public const SCHEMES = ['http', 'https', 'mailto', 'tel', 'sms'];

Allowlist rather than blocklist, so data:, vbscript: and whatever the next one turns out to be are refused by not being on it. The fiddly part is deciding what counts as a scheme at all: /a/b:c is a path, #a:b is an anchor, and only a colon before any slash, question mark or hash introduces a scheme.

Why they had been sitting there

Both were written down. Both had a paragraph explaining exactly what was wrong. Neither was fixed, for eleven days, because they arrived during a simplification pass while I was working on something else, and "write it in the gate file" felt like dealing with it.

It is not nothing: they were still there to find. But a list of known bugs is a decision deferred, not a decision made, and the deferral had no expiry on it. The thing that forced it was the version number, because 1.0.0 promises the API will not move and a bug in a public method is much more expensive to fix after that promise than before it.

The one I did defer, and said so

The same list has six other items: no revisions UI, an unchecked file_get_contents, six public methods with no callers, a duplicated FAQ schema builder. Those are in the release notes as deferred with the reason, rather than quietly left.

The difference is not size. It is whether leaving it writes bad data into someone's database, which the canonical bug was doing every time a menu item was created.

Where it stands

Both fixed and shipped in v1.0.0, with tests, including one that asserts a hostile scheme never reaches the rendered page.

The lesson I want to keep: a checklist is only worth writing if something eventually forces you to read it. A release is that thing. Without one, the file is a museum of intentions.

Last updated 13 Sep 2026 · filed under Laravel, filamentphp, plugin, tools, web application, seo

Every entry on this project

28 build notes, in order

Including the ones where nothing worked. You are on part 27.

Read the series →

FAQ

Frequently asked questions

Why does a page served at the site root often end up with two URLs?

Because the routing and the URL builder are written at different times by different reasoning. The route handles an empty path by falling back to a known slug, and the URL builder concatenates that same slug, so the canonical, the sitemap and any stored links point at the named copy while the root also serves it. Fix the builder to return the root for that slug, and redirect the named URL to it with a 301 so the duplicate stops being reachable. Check anywhere URLs are written into data, such as navigation menus, because those keep the old value.

Is escaping enough to make a user-supplied URL safe in an href?

No. Escaping stops the value breaking out of the attribute and does nothing about the scheme, so javascript: and data: URLs survive it and run when clicked. Validate against an allowlist of schemes such as http, https, mailto, tel and sms, and replace anything else with a harmless placeholder. Be careful detecting the scheme: a colon only introduces one when it appears before any slash, question mark or hash, otherwise ordinary paths and anchors get rejected.

Does a vulnerability that needs admin access still need fixing?

Usually yes, because admin access is rarely one trusted person. Content management systems get handed to clients, agencies, interns and contractors, and the payload runs against every visitor rather than against the person who typed it. Judge it by who is harmed rather than by who can trigger it. When the mitigation is a few lines and it does not restrict any legitimate use, the cost side of the argument is basically empty.

What is the point of writing a known bug into a checklist if nobody reads it?

It only pays off if something later forces the read. A release is the natural forcing function, especially the one where you promise an API will stop moving, because the cost of fixing a public method rises sharply after that. Give the list a scheduled read rather than trusting that someone will browse it. And when you defer an item, say so in the release notes with the reason, so deferred and forgotten stop looking identical from the outside.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from Filament Atelier

All 28 entries →
  • A version number changing from 0.5.0 to 1.0.0 beside a list of deferred items.
    Filament Atelier

    · part 28 of 28

    Tagging 1.0.0 with four features missing, on purpose

    The gate list had thirteen items. Six went in, seven did not, and the tag went out anyway. What the number promises is that the API stops moving, not that the feature list is finished, and conflating those two is how packages sit at 0.x for three years.

  • The same block of JavaScript appearing in three different Blade layout files.
    Filament Atelier

    · part 26 of 28

    A script in three layouts is a contract nobody signed

    The editor's preview needed a few lines of JavaScript in the page it renders. I put them in the shipped layout, then copied them into two more. Anyone writing their own layout had to copy them too, and missing them broke half the editor with no error at all.