<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Qasim Parray</title>
    <description>The latest articles on DEV Community by Qasim Parray (@abyzgenic).</description>
    <link>https://dev.to/abyzgenic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4120845%2Fdf746bdb-4b77-4db2-8b04-ed1d2da71842.png</url>
      <title>DEV Community: Qasim Parray</title>
      <link>https://dev.to/abyzgenic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abyzgenic"/>
    <language>en</language>
    <item>
      <title>Uptime Kuma Docker Setup: What I Change Before Trusting It</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:39:40 +0000</pubDate>
      <link>https://dev.to/abyzgenic/uptime-kuma-docker-setup-what-i-change-before-trusting-it-37en</link>
      <guid>https://dev.to/abyzgenic/uptime-kuma-docker-setup-what-i-change-before-trusting-it-37en</guid>
      <description>&lt;p&gt;I found out a client's site had been down for six hours the way nobody wants to: the client told me. A WhatsApp message at breakfast, one line, no greeting. The server was fine. A certificate renewal had failed quietly, the browser was throwing a full-page warning, and my only monitoring was me occasionally opening the site and thinking "looks okay."&lt;/p&gt;

&lt;p&gt;That was the week I stopped treating monitoring as a someday task. I wanted something I could host myself, that cost nothing per monitor, and that I could set up in an evening. &lt;a href="https://github.com/louislam/uptime-kuma" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; fit. It is also very easy to install badly, which is the part most tutorials skip.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: the default Docker setup works, but I change four things before I trust it with a client's name on the status page. Where it runs, who can reach port 3001, how I get alerted when the monitor itself dies, and what I do with the data folder. The rest of this post is those four, in the order I get bitten by them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the default compose file gives you
&lt;/h2&gt;

&lt;p&gt;The project's README shows the fastest path: make a folder, download a &lt;code&gt;compose.yaml&lt;/code&gt;, run &lt;code&gt;docker compose up -d&lt;/code&gt;. I checked the file as it stands today and it is short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:2&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/app/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3001:3001"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. One container, one bind mount for state, one port. It pins to the major version 2 tag, so you get minor updates when you pull but not a surprise jump to a different major release. I like that default. I have been burned by &lt;code&gt;latest&lt;/code&gt; before, and not only with this tool.&lt;/p&gt;

&lt;p&gt;What you get on first launch is a pleasant surprise if you have used paid monitors. HTTP and keyword checks, TCP, ping, DNS, a push monitor for cron jobs, and a Docker container monitor. Check intervals go down to 20 seconds. Notifications can go to Telegram, Discord, Slack, email and, according to the README, more than 90 other services. Multiple status pages, each mappable to its own domain. That covers what most small agencies pay a monthly fee for.&lt;/p&gt;

&lt;p&gt;The setup is fine for a laptop. It is not fine for a VPS with a public IP, and the reason is on the &lt;code&gt;ports&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Port 3001 is open to the world
&lt;/h2&gt;

&lt;p&gt;Writing &lt;code&gt;"3001:3001"&lt;/code&gt; in a compose file binds the port on every network interface. The README says as much: Uptime Kuma is now running on all network interfaces. On a home machine behind a router, nobody cares. On a Hetzner box with a public address, you have just put an admin login on the internet at a predictable port, and the first thing the setup screen asks you to do is create the admin account.&lt;/p&gt;

&lt;p&gt;I have seen people leave that screen open for a day. Whoever loads it first becomes the admin. That is a bad race to run.&lt;/p&gt;

&lt;p&gt;The README mentions the fix itself: limit exposure to localhost. In compose terms that is one extra prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3001"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the container only answers on the loopback interface. To reach the dashboard you need something in front of it. I use a reverse proxy with automatic certificates, and if you are choosing between the common options, I wrote up &lt;a href="https://abrarqasim.com/blog/caddy-vs-nginx-small-vps-two-line-config-and-where-nginx-still-wins" rel="noopener noreferrer"&gt;how Caddy and nginx compare on a small VPS&lt;/a&gt;. The Caddy config for this is two lines: the hostname, then a &lt;code&gt;reverse_proxy 127.0.0.1:3001&lt;/code&gt;. Done.&lt;/p&gt;

&lt;p&gt;If you would rather open no inbound ports at all, a tunnel works too. I covered that route in &lt;a href="https://abrarqasim.com/blog/cloudflare-tunnel-how-i-stopped-opening-ports-for-clients" rel="noopener noreferrer"&gt;the Cloudflare Tunnel post&lt;/a&gt;. Either way, the dashboard belongs behind a hostname with TLS, not an IP and a port number.&lt;/p&gt;

&lt;p&gt;One more thing on the first-run screen. Create the admin account immediately, pick a long passphrase, and turn on two-factor auth. The README lists 2FA as a built-in feature, so there is no excuse. It takes a minute and it removes the most likely way someone else ends up owning your monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monitor cannot watch itself die
&lt;/h2&gt;

&lt;p&gt;This is the mistake I made first, and it is the one that matters most.&lt;/p&gt;

&lt;p&gt;If Uptime Kuma runs on the same server as the sites it watches, then a dead server takes out both the sites and the thing that was supposed to tell you. Your phone stays silent. Silence looks exactly like "everything is fine." I only noticed my version of this because a test outage produced zero alerts, which was embarrassing, since I had caused the outage on purpose.&lt;/p&gt;

&lt;p&gt;There are two fixes, and I use both.&lt;/p&gt;

&lt;p&gt;The first is location. Run Uptime Kuma on a different provider or at least a different machine from the thing it watches. A cheap second VPS in another datacenter is enough. It does not need much: the process is a single Node app with a small database behind it, so it will sit comfortably on the smallest plan most providers sell. If your sites are on one host, put the monitor somewhere else. That is the entire rule.&lt;/p&gt;

&lt;p&gt;The second is a heartbeat going the other way. Uptime Kuma has a push monitor type: instead of the tool calling out to a URL, something calls in to a URL it generates, and if the call stops arriving, the monitor goes red. I point a tiny cron job at it from the box that runs Uptime Kuma, and I watch that push monitor from a free external ping service. Now if the monitor machine dies, something else notices. It sounds like turtles all the way down, but you only need one level of it. A free tier at an outside service is plenty for a single check.&lt;/p&gt;

&lt;p&gt;A smaller version of the same mistake: alerts that go to an email address on the domain you are monitoring. When the mail server is down along with everything else, the alert never lands. Send to Telegram, or a personal address on a different domain. I use a Telegram bot for anything urgent because the notification arrives on my phone within seconds and I cannot sleep through it as easily as an email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning the checks so you trust the alerts
&lt;/h2&gt;

&lt;p&gt;A monitor that cries wolf gets muted. A muted monitor is worse than none, because you think you have coverage.&lt;/p&gt;

&lt;p&gt;My first week I left every check at the lowest interval and every alert on the first failure. A single dropped packet paged me at 3 a.m. The fix is dull. Set retries to 2 or 3 before a monitor flips to down, and keep the interval at a minute for client sites, longer for anything internal. A 20-second interval is available, and I use it for exactly one thing, a payments endpoint where thirty seconds of downtime has a price.&lt;/p&gt;

&lt;p&gt;Check what the monitor actually checks, too. An HTTP monitor that accepts any 2xx will happily report "up" for a site that now serves a blank page or a maintenance banner. For client sites I switch to a keyword monitor and look for a string I know sits in the footer or the page title. If the string disappears, the page broke, even when the server still answers 200. That one change caught a broken deploy for me before the client noticed, which made up for the six hours of embarrassment.&lt;/p&gt;

&lt;p&gt;Certificates deserve their own monitor. Uptime Kuma shows certificate info and can alert before expiry. Turn it on for every HTTPS check. It would have saved me that breakfast message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups and updates for the data folder
&lt;/h2&gt;

&lt;p&gt;Everything the tool knows lives in the &lt;code&gt;./data&lt;/code&gt; directory from the compose file: the database, your monitors, your notification settings. Lose it and you rebuild by hand from memory. The README also warns that network file systems like NFS are not supported for this path, so keep it on local disk or a normal volume.&lt;/p&gt;

&lt;p&gt;I back that folder up with the same job that handles the rest of my small servers, a nightly copy off the machine. If you have not set one up, the &lt;a href="https://docs.docker.com/reference/compose-file/services/" rel="noopener noreferrer"&gt;Docker Compose services reference&lt;/a&gt; is where I would start for understanding how the bind mount and &lt;code&gt;restart&lt;/code&gt; policy fit together. The &lt;code&gt;unless-stopped&lt;/code&gt; policy in the default file means the container comes back after a reboot, but it will stay down if you stopped it on purpose. That is the behavior I want.&lt;/p&gt;

&lt;p&gt;For updates I do it manually and on a schedule. Pull the new image, recreate the container, look at the dashboard, done. The project keeps update instructions on its wiki, and I read the release notes before any major jump. Because the compose file pins to the major tag, a routine pull stays inside version 2.&lt;/p&gt;

&lt;p&gt;Do one restore test, even a rough one. Copy the data folder to a scratch machine, start a container against it, and confirm your monitors show up. Mine took ten minutes and found that I had forgotten to include the folder in the backup job at all. I would much rather learn that on a Tuesday afternoon than during an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do this week
&lt;/h2&gt;

&lt;p&gt;If you run client sites and have no monitoring, set up Uptime Kuma on a machine that is not the one hosting those sites. Bind it to localhost, put it behind a hostname with TLS, and create the admin account with 2FA before you do anything else. Add one keyword monitor and one certificate check per site, then route alerts to Telegram.&lt;/p&gt;

&lt;p&gt;Then break something on purpose. Stop a site, wait for the alert, and time how long it takes to reach your phone. If it never arrives, you just found the real work. I do this kind of setup for small teams as part of my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;freelance engineering work&lt;/a&gt;, and the failed test alert is the single most common thing I find on day one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/uptime-kuma-docker-setup-what-i-change-before-trusting-it/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>uptimekuma</category>
      <category>docker</category>
      <category>selfhosting</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>iframe Sandbox Lessons From the ChatGPT Account Takeover</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 04 Nov 2026 16:39:31 +0000</pubDate>
      <link>https://dev.to/abyzgenic/iframe-sandbox-lessons-from-the-chatgpt-account-takeover-ap</link>
      <guid>https://dev.to/abyzgenic/iframe-sandbox-lessons-from-the-chatgpt-account-takeover-ap</guid>
      <description>&lt;p&gt;I have a confession about iframes. For years I treated the &lt;code&gt;sandbox&lt;/code&gt; attribute like a seatbelt: I clicked it in, felt safe, and never once asked what the car was doing. Then I read Simon Willison's &lt;a href="https://simonwillison.net/2026/Oct/31/how-we-took-over-chatgpt-accounts/" rel="noopener noreferrer"&gt;link post about how researchers took over ChatGPT accounts&lt;/a&gt; and realised the seatbelt was fine. The problem was a door nobody had locked, three feet away.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: the sandbox held. The attack worked because the parent page had a &lt;code&gt;message&lt;/code&gt; handler that wrote whatever it received into the DOM with &lt;code&gt;innerHTML&lt;/code&gt;, and it never asked who was talking. If your app embeds untrusted HTML and also listens for &lt;code&gt;postMessage&lt;/code&gt;, that handler is the thing to audit this week, not the iframe attribute.&lt;/p&gt;

&lt;p&gt;I'll walk through the chain as Simon summarised it, then show the handler pattern I now use in my own code. I'm working from his write-up and not from the original researchers' report, so treat my account of the details as a summary and go read the sources yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the attack chain actually did
&lt;/h2&gt;

&lt;p&gt;ChatGPT lets you preview interactive HTML apps in a sandboxed iframe. That's the same idea as the artifacts pattern you may have seen elsewhere. The point of the sandbox is that the HTML inside is untrusted. It might be written by a stranger, shared through a link, and opened by you while signed in.&lt;/p&gt;

&lt;p&gt;According to Simon's summary, the researchers at Parameter Labs found a chain of small problems. The parent page used the MathLive library for graph support, and MathLive ships its own virtual keyboard. That keyboard can be triggered in the parent page through &lt;code&gt;postMessage()&lt;/code&gt;. The receiving code used &lt;code&gt;innerHTML&lt;/code&gt;, so anything able to send a message could push HTML into the parent. Nothing stopped a sandboxed preview frame from sending one.&lt;/p&gt;

&lt;p&gt;Inserting HTML is not the same as running script. &lt;code&gt;innerHTML&lt;/code&gt; won't execute a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; element you hand it. But it will happily create an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;, and a new iframe created by the parent has no sandbox unless the parent adds one. So the attacker got an unsandboxed frame inside the page.&lt;/p&gt;

&lt;p&gt;Then came the content security policy. It blocked inline scripts and only allowed scripts from a short list of domains. One of those domains was YouTube, and the YouTube oEmbed endpoint could be coaxed into reflecting JavaScript back in a form that ran. With script running on the ChatGPT origin, fetching private data from the APIs and sending it out through an image request was, in Simon's word, trivial. Share a link, wait for a signed-in person to open it, collect their data.&lt;/p&gt;

&lt;p&gt;I find the shape of this more useful than the specific bug. Four separate decisions, each defensible in a code review on its own, added up to account takeover. A message handler that trusts its sender. A DOM write that accepts markup. A frame that inherits no restrictions. An allowlist entry for a big, friendly domain. Remove any single one and the chain breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the sandbox attribute wasn't the problem
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sandbox&lt;/code&gt; attribute did its job. A sandboxed frame without &lt;code&gt;allow-same-origin&lt;/code&gt; gets an opaque origin, so it can't read the parent's cookies or storage, and it can't touch the parent's DOM directly. What it can still do, if you let it run scripts, is call &lt;code&gt;window.parent.postMessage()&lt;/code&gt;. That channel is deliberate. It's the supported way for a restricted frame to talk to the page around it.&lt;/p&gt;

&lt;p&gt;So the real question is what your page does when a message arrives. Think of that handler as a public API endpoint, because functionally it is one. Any frame that can reach your window can call it, including frames you never meant to host.&lt;/p&gt;

&lt;p&gt;Here's the pattern that worries me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: trusts every message from every frame&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;show-keyboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here checks &lt;code&gt;event.origin&lt;/code&gt;. Nothing checks &lt;code&gt;event.source&lt;/code&gt;. The payload shape is whatever the sender says it is. And the &lt;code&gt;innerHTML&lt;/code&gt; write means the sender controls markup, which is the exact step the researchers needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A handler I would actually ship
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage" rel="noopener noreferrer"&gt;MDN documentation for &lt;code&gt;postMessage&lt;/code&gt;&lt;/a&gt; is blunt about this: always verify the sender's identity using the &lt;code&gt;origin&lt;/code&gt; and possibly &lt;code&gt;source&lt;/code&gt; properties, and be wary of what you do with the data. Here's my current version, rewritten after reading the ChatGPT write-up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: known sender, known shape, no markup from outside&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEYBOARD_ORIGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyboardFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keyboard-frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;KEYBOARD_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;keyboardFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentWindow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;show-keyboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layouts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layouts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things changed. The handler compares the origin against one exact string, not a prefix and not &lt;code&gt;endsWith&lt;/code&gt;. It also compares &lt;code&gt;event.source&lt;/code&gt; to the specific frame I expect, because two different frames can share an origin. The payload is validated against a closed set of values. And nothing from the message ever becomes markup. It becomes a &lt;code&gt;data-&lt;/code&gt; attribute that my own CSS reads.&lt;/p&gt;

&lt;p&gt;That last change is the one that matters most. If a handler needs to render something the sender supplied, use &lt;code&gt;textContent&lt;/code&gt;, or build elements with &lt;code&gt;createElement&lt;/code&gt;. If it genuinely has to accept markup, run it through a sanitizer such as &lt;a href="https://github.com/cure53/DOMPurify" rel="noopener noreferrer"&gt;DOMPurify&lt;/a&gt; and then think hard about whether you really needed markup.&lt;/p&gt;

&lt;p&gt;One more trap with sandboxed frames: their origin is the string &lt;code&gt;"null"&lt;/code&gt;. If you ever write &lt;code&gt;if (event.origin === 'null')&lt;/code&gt; to accept messages from a sandboxed preview, you've just accepted messages from every sandboxed frame on the internet. It's an easy line to write when a preview pane refuses to load, so search your code for it. The fix is checking &lt;code&gt;event.source&lt;/code&gt; against a frame reference you created, because origin can't tell opaque frames apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The combination that disables your own sandbox
&lt;/h2&gt;

&lt;p&gt;While I'm confessing, here's the other classic. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe" rel="noopener noreferrer"&gt;MDN iframe reference&lt;/a&gt; warns that combining &lt;code&gt;allow-scripts&lt;/code&gt; and &lt;code&gt;allow-same-origin&lt;/code&gt; on a frame whose content is served from your own origin lets that content reach into the parent and remove the sandbox attribute entirely. It's a one-line escape. People add both flags because the embedded app breaks without same-origin access to local storage, the app starts working, and the ticket closes.&lt;/p&gt;

&lt;p&gt;If you host user-supplied HTML, serve it from a different registrable domain, not just a different subdomain, and keep &lt;code&gt;allow-same-origin&lt;/code&gt; off. Separate origin for untrusted content is the boring, old, reliable answer. Google, GitHub and others have used the same trick for years for exactly this reason.&lt;/p&gt;

&lt;p&gt;My rule of thumb now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://preview.example-usercontent.net/abc123"&lt;/span&gt;
  &lt;span class="na"&gt;sandbox=&lt;/span&gt;&lt;span class="s"&gt;"allow-scripts"&lt;/span&gt;
  &lt;span class="na"&gt;referrerpolicy=&lt;/span&gt;&lt;span class="s"&gt;"no-referrer"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scripts allowed, nothing else, different site entirely. If the preview needs forms or popups I add those flags one at a time and write down why in the commit message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The allowlist entry nobody reviews
&lt;/h2&gt;

&lt;p&gt;The detail in the story that bothers me most is the YouTube entry in the CSP. I'd have waved it through too. Embedding videos is a normal product need, and YouTube is not a hostile domain. But a script-src allowlist trusts everything that domain will serve, including endpoints that reflect input. Large shared domains tend to have many such endpoints.&lt;/p&gt;

&lt;p&gt;If you want a stricter setup, nonce-based CSP with &lt;code&gt;strict-dynamic&lt;/code&gt; avoids the host allowlist problem altogether. I wrote about the nonce route, and the one inline style attribute I couldn't lock down, in &lt;a href="https://abrarqasim.com/blog/react-security-best-practices-csp-nonce-the-style-attribute-i-couldnt-lock-down/" rel="noopener noreferrer"&gt;my React security post&lt;/a&gt;. It isn't free. Nonces mean server-rendered headers and some careful plumbing. But a host allowlist has a failure mode you can't see from the policy text, and a nonce doesn't.&lt;/p&gt;

&lt;p&gt;I also want to say something about the bounty. Simon points out that the reported $5,000 payout seemed low for a bug that could expose a signed-in user's private data. I agree with him, with the caveat that I don't know the programme's rules or how OpenAI scored it. The takeaway for me is practical: a payout doesn't tell you how much the bug would have cost if someone less friendly found it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to do this week
&lt;/h2&gt;

&lt;p&gt;Open your codebase and search for &lt;code&gt;addEventListener('message'&lt;/code&gt; and &lt;code&gt;onmessage&lt;/code&gt;. Every hit is a trust boundary. For each one, answer three questions: who can send to this window, what happens if they lie about the payload shape, and does any value end up in &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;outerHTML&lt;/code&gt;, &lt;code&gt;insertAdjacentHTML&lt;/code&gt; or &lt;code&gt;document.write&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Then search for &lt;code&gt;sandbox=&lt;/code&gt; and look for any frame carrying both &lt;code&gt;allow-scripts&lt;/code&gt; and &lt;code&gt;allow-same-origin&lt;/code&gt;. If the content in that frame isn't entirely yours, you have the same shape of problem the researchers exploited. I do this kind of review for clients, and you can see &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;what I work on&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do the grep before lunch tomorrow. Fix the worst &lt;code&gt;innerHTML&lt;/code&gt; first, because that single change would have broken the chain described above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/iframe-sandbox-postmessage-chatgpt-html-preview-the-handler-i-would-audit/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>iframe</category>
      <category>postmessage</category>
    </item>
    <item>
      <title>Postgres Restore Drill: What a Green pg_dump Exit Code Proves</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:39:15 +0000</pubDate>
      <link>https://dev.to/abyzgenic/postgres-restore-drill-what-a-green-pgdump-exit-code-proves-80m</link>
      <guid>https://dev.to/abyzgenic/postgres-restore-drill-what-a-green-pgdump-exit-code-proves-80m</guid>
      <description>&lt;p&gt;Short version for the impatient: a &lt;code&gt;pg_dump&lt;/code&gt; that exits with code 0 tells you the dump finished. It doesn't tell you the dump restores, and it says nothing about whether the restored database has your data in it. The only way to know is to restore it somewhere disposable and look.&lt;/p&gt;

&lt;p&gt;I've been thinking about this since two Postgres announcements landed on postgresql.org in the same week. &lt;a href="https://www.postgresql.org/about/news/backupdrill-open-source-supabase-postgres-backups-with-automated-restore-drills-3413/" rel="noopener noreferrer"&gt;BackupDrill&lt;/a&gt; is an open-source CLI for Supabase projects that restores each backup into a throwaway container. &lt;a href="https://www.postgresql.org/about/news/safegrd-postgresql-backups-that-are-restored-and-checked-on-a-schedule-3414/" rel="noopener noreferrer"&gt;SafeGrd&lt;/a&gt; does a similar thing as a service. Two separate projects shipping the same idea in a week tells me a lot of people are tired of finding out about broken backups on the worst possible day. I haven't used either one yet. What follows is the DIY version I'd build first, so you can judge what the tools add.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a green pg_dump actually proves
&lt;/h2&gt;

&lt;p&gt;Not much. When &lt;code&gt;pg_dump&lt;/code&gt; exits cleanly, you know it connected, read the catalogs, streamed table data, and wrote an archive to disk. The &lt;a href="https://www.postgresql.org/docs/current/app-pgdump.html" rel="noopener noreferrer"&gt;pg_dump documentation&lt;/a&gt; is clear that it takes a consistent snapshot, which is great, but consistent isn't the same as restorable.&lt;/p&gt;

&lt;p&gt;Here are the ways a successful dump still fails you. The archive can be truncated if the disk filled up and your script ignored the error from a pipe. The dump can come from a role that couldn't see some schemas, so those schemas are silently missing. A dump from a newer major version can't be restored by an older &lt;code&gt;pg_restore&lt;/code&gt;, so a staging box on an old version can tell you nothing about a production archive. And if your cron job redirects errors to a log nobody reads, a run that dumped zero rows looks identical to a good one.&lt;/p&gt;

&lt;p&gt;Notice the pattern. Every one of those failures happens at dump time and surfaces at restore time, which is months later. That gap is the whole problem.&lt;/p&gt;

&lt;p&gt;Here's what BackupDrill says its drill checks, according to the announcement: the archive checksum, that &lt;code&gt;pg_restore&lt;/code&gt; completed, that post-data objects like indexes, constraints and triggers were created, that every table is present, and that tables which held data aren't empty. It also records how long the restore took. I like that list because it's specific. It goes past "the command returned 0" and asks what a restored database should look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  A restore drill in about twenty lines
&lt;/h2&gt;

&lt;p&gt;You don't need a product for the first version. You need Docker, a dump file in custom format, and a script that fails loudly. Custom format (&lt;code&gt;pg_dump -Fc&lt;/code&gt;) matters here, because &lt;code&gt;pg_restore&lt;/code&gt; can list and selectively restore from it, and plain SQL dumps can't do that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# restore-drill.sh /path/to/backup.dump&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?path&lt;span class="p"&gt; to a pg_dump -Fc file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"drill-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill postgres:17 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'docker stop "$NAME" &amp;gt;/dev/null'&lt;/span&gt; EXIT

&lt;span class="k"&gt;until &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;3   &lt;span class="c"&gt;# the image runs a temporary server first; see the gotcha below&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; createdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres drill

&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_restore &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; drill &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exit-on-error&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"restore took &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; start &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; drill &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SQL&lt;/span&gt;&lt;span class="sh"&gt;'
select 'tables', count(*) from pg_tables where schemaname = 'public';
select 'indexes', count(*) from pg_indexes where schemaname = 'public';
select 'triggers', count(*) from pg_trigger where not tgisinternal;
&lt;/span&gt;&lt;span class="no"&gt;SQL
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices in there deserve a defense. &lt;code&gt;--exit-on-error&lt;/code&gt; makes &lt;code&gt;pg_restore&lt;/code&gt; stop at the first failure, instead of its default of printing errors and carrying on, which is how a half-restored database ends up looking fine. &lt;code&gt;--no-owner&lt;/code&gt; avoids a flood of errors about roles that exist in production and not in the throwaway container. You lose the ownership check, and I think that's an acceptable trade for a drill. The &lt;a href="https://www.postgresql.org/docs/current/app-pgrestore.html" rel="noopener noreferrer"&gt;pg_restore reference&lt;/a&gt; lists both flags if you want to read the details.&lt;/p&gt;

&lt;p&gt;Now the gotcha I promised. The official &lt;code&gt;postgres&lt;/code&gt; Docker image starts a temporary server to run its init scripts, shuts it down, then starts the real one. &lt;code&gt;pg_isready&lt;/code&gt; can report success against the temporary server, and your next command then lands in the gap between the two. The &lt;code&gt;sleep 3&lt;/code&gt; is ugly. A cleaner fix is to retry &lt;code&gt;createdb&lt;/code&gt; in a loop, and I'd do that in anything that runs unattended.&lt;/p&gt;

&lt;p&gt;Match the major version of the container to the server you dump from. Restoring a dump from Postgres 17 into a 15 container is the kind of mismatch a drill should catch, and that's the point, but then the failure tells you about your drill and not about your backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counts are not enough
&lt;/h2&gt;

&lt;p&gt;The script above prints table, index and trigger counts, and those numbers only help if you know what they should be. So record the expected values at dump time and compare. The cheapest version is to write a small manifest next to every dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  select 'orders', count(*) from orders
  union all select 'customers', count(*) from customers
  union all select 'invoices', count(*) from invoices
"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; backup-2026-10-29.manifest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the drill restores, run the same query against the container and &lt;code&gt;diff&lt;/code&gt; the two files. Use exact &lt;code&gt;count(*)&lt;/code&gt; for the tables that matter and skip &lt;code&gt;pg_stat_user_tables.n_live_tup&lt;/code&gt;, which is an estimate and will drift from the real number. The numbers won't match if rows were written between the dump and the manifest, so generate both back to back on a quiet system, or accept a tolerance and say so in the script.&lt;/p&gt;

&lt;p&gt;Table counts catch missing data. They don't catch wrong data. For the one or two tables a business can't lose, I'd add a single spot check that mirrors something a human cares about, like the sum of invoice totals for last month. If that sum is off by a cent after restore, you want to know before a client asks.&lt;/p&gt;

&lt;p&gt;Also compare sequences. &lt;code&gt;pg_dump&lt;/code&gt; records sequence values, so &lt;code&gt;last_value&lt;/code&gt; should match between source and restore. If it doesn't, you have a partial restore that still passes every row count and then fails on the first insert with a duplicate key error. It's a small query, and I'd rather run it in a drill than discover it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tools fit
&lt;/h2&gt;

&lt;p&gt;Once you have the script above, the interesting question is what BackupDrill and SafeGrd would add, and the answer is mostly scheduling, storage and alerting. BackupDrill's announcement describes backups streamed to any S3-compatible bucket (S3, R2, Backblaze B2, Wasabi) with a checksummed manifest, plus a hosted service that runs drills on a schedule and sends email alerts when one fails. SafeGrd describes age encryption on your host before upload and Object Lock on the bucket, which protects backups from deletion, and that's a real concern the script above ignores.&lt;/p&gt;

&lt;p&gt;I'd think hard about scope before adopting either. BackupDrill covers Supabase-hosted Postgres only for now, per its own announcement, and dumps the &lt;code&gt;public&lt;/code&gt; schema by default. If you run Postgres on a VPS, that rules it out today. SafeGrd supports Postgres 13 and later and lists Supabase, Neon and RDS from a connection string, which is broader. In both cases you're trusting a third party with the one job where being wrong is expensive, so read what leaves your host and who holds the keys. SafeGrd says the backup data doesn't go through its servers, and that's the right claim to want. I'd still test it with a database I can afford to lose.&lt;/p&gt;

&lt;p&gt;If you already run your own backups on a small server, I wrote up retention and restore testing in my &lt;a href="https://abrarqasim.com/blog/restic-backup-small-vps-postgres-dump-retention-restore-test" rel="noopener noreferrer"&gt;restic and Postgres dump post&lt;/a&gt;. The drill in this post slots in after that pipeline, as the step that proves the files are usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it a habit
&lt;/h2&gt;

&lt;p&gt;A drill that runs once teaches you very little, so schedule it. On a small box a systemd timer or a cron entry is plenty. I do this kind of operational hardening for clients, and you can see the sort of work on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;. The pattern is always the same: the backup job and the drill are separate jobs, and the drill alerts you when it fails, not when it passes. A passing drill that emails you every night trains you to ignore it.&lt;/p&gt;

&lt;p&gt;Two decisions to make up front. How old a backup can be before you consider it a failure? And how long a restore can take before you consider it a failure? The second one is the number people skip, and it's the one that matters during an outage. If your restore takes 40 minutes, your recovery objective is at least 40 minutes, whatever the plan on paper says.&lt;/p&gt;

&lt;p&gt;This week, take your most recent dump and run the script above against it on your laptop. Time the restore, write the number in your runbook, and note the first thing that goes wrong. Something will.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/postgres-restore-drill-pg-dump-green-exit-code-proves-little/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>backups</category>
      <category>pgdump</category>
      <category>devops</category>
    </item>
    <item>
      <title>Next.js Bundle Analyzer in 16.4: The Import That Defeats Lazy Load</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:39:13 +0000</pubDate>
      <link>https://dev.to/abyzgenic/nextjs-bundle-analyzer-in-164-the-import-that-defeats-lazy-load-17mb</link>
      <guid>https://dev.to/abyzgenic/nextjs-bundle-analyzer-in-164-the-import-that-defeats-lazy-load-17mb</guid>
      <description>&lt;p&gt;Confession: bundle analyzers are the tool I know I should open more often and mostly don't. The usual finding is something small and slightly embarrassing, like a date library imported for one &lt;code&gt;format()&lt;/code&gt; call and sitting in the initial chunk of a page with no dates on it. You fix it, feel smug for a day, and forget to check again until someone complains about load time.&lt;/p&gt;

&lt;p&gt;So when the Next.js team published &lt;a href="https://nextjs.org/blog/ship-less-javascript-with-ai" rel="noopener noreferrer"&gt;Ship less JavaScript in Next.js with your AI agent&lt;/a&gt; on 28 October, I read it twice. Short version for the impatient: Next.js 16.4 adds an agent skill and a better &lt;code&gt;next analyze&lt;/code&gt;, and the most useful idea in the post is a boring one about imports that quietly undo each other. I haven't run the skill on a production app yet, so this isn't a review. It's what I would check first, and why I'm more interested in the analyzer than in the agent bolted onto it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lazy import that never deferred anything
&lt;/h2&gt;

&lt;p&gt;Here is the example from the Next.js post, nearly verbatim in spirit. You have a heavy component and you want it out of the initial bundle, so you wrap it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// HeavyComponentLoader.js&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lazy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HeavyComponentLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./HeavyComponent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks right. React's &lt;a href="https://react.dev/reference/react/lazy" rel="noopener noreferrer"&gt;lazy&lt;/a&gt; documentation says the code for a lazy component isn't loaded until it first renders, and the Next.js &lt;a href="https://nextjs.org/docs/app/guides/lazy-loading" rel="noopener noreferrer"&gt;lazy loading guide&lt;/a&gt; says much the same about &lt;code&gt;next/dynamic&lt;/code&gt;. Both are true. The catch is that the bundler works on the whole module graph, not on your intentions. If any other module in the same bundle does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Toolbar.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;HeavyComponent&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./HeavyComponent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then &lt;code&gt;HeavyComponent&lt;/code&gt; is part of the initial chunk anyway, and your lazy wrapper becomes a decoration. One static import, three folders away, written by someone who never saw your loader. The bundle doesn't warn you. Lighthouse just gets a little worse and nobody connects it to a pull request from last month.&lt;/p&gt;

&lt;p&gt;Picture a chart library that is lazy loaded on a reports page, which feels like a good job done. Then someone adds a small summary card to the home page that imports one helper from the same chart file, and the whole library moves into the shared chunk. You'd stare at the lazy call for an hour, because the lazy call is fine. The bug is somewhere else entirely. I haven't been bitten by this exact case on a client project, but it's the shape of bug I'd expect to find in any app with more than one contributor.&lt;/p&gt;

&lt;p&gt;The old way to find this was to open a treemap, spot the big rectangle, and then grep the codebase for every place that imports it. It works. It's also slow, and you only do it when someone is already complaining about load time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What next analyze gives you without any agent
&lt;/h2&gt;

&lt;p&gt;Before the agent part, the human tooling. In 16.1 the team shipped the first Turbopack Bundle Analyzer, and 16.4 adds a few things that matter more than the headline. You run &lt;code&gt;next analyze&lt;/code&gt; in your app directory, and you now land on a route summary that lists your largest client routes. Open one and there's a table you can sort by size, with each source marked as initial load, behind an async boundary, or both.&lt;/p&gt;

&lt;p&gt;That last label is the one I care about. A module marked "Initial + async paths" is exactly the lazy-import bug above: it's loaded lazily from one place and eagerly from another. In the post's illustrative blog app, a search component sits at 18 kB with that label. You can see the problem in one row, without reading a treemap.&lt;/p&gt;

&lt;p&gt;Two more features I'd use right away. The analyzer now saves a snapshot of each analysis automatically, and you can diff two snapshots, which gives you a before and after for any change. The post's example goes from 123 kB to 81 kB on one route, with a per-file table of what moved. And there's a filter for the critical render path, so you can hide async dependencies and look only at what blocks first paint.&lt;/p&gt;

&lt;p&gt;I'll be blunt about one limitation. All of this needs Turbopack, because the analyzer relies on its module graph. If you're on 16 or later, that's the default and you won't think about it. If you pinned webpack for a plugin that doesn't work elsewhere, you stay with the older &lt;a href="https://github.com/webpack-contrib/webpack-bundle-analyzer" rel="noopener noreferrer"&gt;webpack bundle analyzer&lt;/a&gt; and none of this applies to you yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent skill adds
&lt;/h2&gt;

&lt;p&gt;The skill is installed with &lt;code&gt;npx skills add vercel/next.js --skill next-bundle-optimizer&lt;/code&gt; and run with &lt;code&gt;/next-bundle-optimizer&lt;/code&gt;. According to the post, it exports saved analyses as JSON, rebuilds the module and chunk graphs, finds large groups of connected modules, and uses a minimum cut to pick a small set of import boundaries that would defer them. Then it checks those proposals against your source code and how the components are used.&lt;/p&gt;

&lt;p&gt;The minimum cut is the clever part, and it's a good fit for the problem. "Which handful of imports, if I changed them, would let this whole cluster load later?" is a graph question. It's the question I answer badly by hand, because I follow the first import chain I find and stop. A tool that looks at every chain at once will beat me on that, and I have no pride about it.&lt;/p&gt;

&lt;p&gt;By default it only reports. You have to run &lt;code&gt;/next-bundle-optimizer fix&lt;/code&gt; to have it apply changes, re-run the analysis, and compare snapshots. I like that default. It means the first run on a client codebase is read-only, and I can bill the findings as an audit before anyone touches a file.&lt;/p&gt;

&lt;p&gt;The numbers in the post are large. Across v0 and vercel.com, the team says agents cut nearly two megabytes of compressed JavaScript, with individual wins like 150 KB from an experimental editor loaded on nearly every page, and 255 KB on the Vercel dashboard from markdown rendering and chart drawing that shipped before the agent panel was even open. Those are self-reported results on Vercel's own apps, tuned by the people who wrote the tool. Treat them as proof the approach works, not as a forecast for your app. A typical agency site has fewer megabytes to find. I'd be happy with three or four wins in the 20 to 80 KB range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would not trust it blindly
&lt;/h2&gt;

&lt;p&gt;Deferring code isn't free, and this is where I'd push back on the "let the agent open a pull request" workflow. When you move a 150 KB editor behind a dynamic import, you haven't removed the cost. You've moved it to the moment the user needs the editor. If that moment is a click, the first click now waits on a network request. On a fast connection you won't notice. On a train in poor coverage, the button looks broken.&lt;/p&gt;

&lt;p&gt;So every proposed boundary needs a question: what does the user see while this loads? A spinner, a layout jump, a blank panel? The post says the agent checks how components are used interactively, and I believe it tries. But a model reading source can't feel a 400 ms gap on a mid-range phone. You can.&lt;/p&gt;

&lt;p&gt;My review checklist for each agent change, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the deferred component above the fold or behind an interaction?&lt;/li&gt;
&lt;li&gt;Does the loading state match the final layout, so nothing shifts?&lt;/li&gt;
&lt;li&gt;Did INP get worse on the page, measured on a preview deployment rather than on my laptop?&lt;/li&gt;
&lt;li&gt;Is the saving big enough to justify the extra moving part?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Vercel team says they verified each fix on a preview deployment before shipping, which is the right habit. Copy it. I'd also keep pull requests one change each, the way the post suggests, because a single combined "optimize bundles" diff is impossible to bisect when INP regresses.&lt;/p&gt;

&lt;p&gt;If you are also deciding whether Next.js is the right base for a project in the first place, I went through that trade-off in my &lt;a href="https://abrarqasim.com/blog/nextjs-vs-vite-vinext-1-0-the-use-cache-gap-i-would-check-first" rel="noopener noreferrer"&gt;Next.js vs Vite post&lt;/a&gt;. The bundle tooling above is one more thing that tilts the answer, mostly for teams already on Turbopack.&lt;/p&gt;

&lt;h2&gt;
  
  
  A routine I would run this week
&lt;/h2&gt;

&lt;p&gt;You don't need the agent to get most of the value. Here's the sequence I'm going to use on my next client audit, which is the kind of work I describe on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on a branch, after upgrading to Next.js 16.4&lt;/span&gt;
npx next analyze

&lt;span class="c"&gt;# optional: read-only report from the agent skill&lt;/span&gt;
npx skills add vercel/next.js &lt;span class="nt"&gt;--skill&lt;/span&gt; next-bundle-optimizer
&lt;span class="c"&gt;# then run /next-bundle-optimizer in your agent (no "fix" argument)&lt;/span&gt;

&lt;span class="c"&gt;# export the graph if you want to poke at it yourself&lt;/span&gt;
npx next analyze &lt;span class="nb"&gt;export&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with the route summary and write down the three largest client routes. In each, sort by size and look for two things: any source labeled "Initial + async paths", and any package you can't explain. A date library on a page with no dates counts. Then pick exactly one finding, fix it by hand, take a new snapshot, and read the diff. If the diff shows the number you expected, you understand your bundle. If it doesn't, the import graph is teaching you something, and that's the useful part.&lt;/p&gt;

&lt;p&gt;Only after that would I run the agent in report mode and compare its list with mine. Where we disagree, I want to know why before I apply anything.&lt;/p&gt;

&lt;p&gt;This week, run &lt;code&gt;next analyze&lt;/code&gt; on your biggest route and write down the single largest file in the initial load. If you can't say why it's there, trace its imports until you can.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/nextjs-bundle-analyzer-16-4-the-static-import-that-defeats-lazy-load/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>turbopack</category>
      <category>performance</category>
    </item>
    <item>
      <title>PHP EOL and PHP2Shell: The Build Date Check for Old Branches</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 30 Oct 2026 16:39:11 +0000</pubDate>
      <link>https://dev.to/abyzgenic/php-eol-and-php2shell-the-build-date-check-for-old-branches-3ngg</link>
      <guid>https://dev.to/abyzgenic/php-eol-and-php2shell-the-build-date-check-for-old-branches-3ngg</guid>
      <description>&lt;p&gt;I have a client whose invoicing app still runs PHP 7.4. I've been asking to upgrade it for two years. The answer is always the same: it works, so leave it. On October 21 that answer got a lot harder to defend, because the PHP2Shell fixes shipped for 8.2 through 8.5, and the old branches live in a murkier place.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: if you run PHP 7.4, 8.0 or 8.1, a patched build exists but it carries the same version number as the vulnerable one, so &lt;code&gt;php -v&lt;/code&gt; cannot tell you if you're safe. If you run 7.3 or older, no fix exists at all. The rest of this post is how to check, what to do about each case, and why I think "it works" has expired as an argument.&lt;/p&gt;

&lt;p&gt;If you're on a supported branch, my &lt;a href="https://abrarqasim.com/blog/php-8-5-12-security-update-rce-patch-checklist-php-fpm-mod-php" rel="noopener noreferrer"&gt;PHP 8.5.12 patch checklist&lt;/a&gt; is the better starting point. This post is about the PHP end of life problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which branches got what
&lt;/h2&gt;

&lt;p&gt;The fixed versions for supported branches are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Those are the easy ones.&lt;/p&gt;

&lt;p&gt;The older branches are where it gets strange. According to &lt;a href="https://support.plesk.com/hc/en-us/articles/44397729587351-Vulnerability-CVE-2026-109618-and-CVE-2026-109619-PHP2Shell-Remote-code-execution-in-PHP-shipped-with-Plesk" rel="noopener noreferrer"&gt;Plesk's patch summary&lt;/a&gt;, PHP 8.1, 8.0 and 7.4 are fixed in builds dated October 21, 2026, and those builds keep the version numbers 8.1.34, 8.0.30 and 7.4.33. Plesk marks those three as Linux only. PHP 5.6, 7.2 and 7.3 are not patched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/" rel="noopener noreferrer"&gt;Rapid7&lt;/a&gt; describes the 7.4, 8.0 and 8.1 fixes as limited source backports supplied by the PHP project. &lt;a href="https://watchtowr.com/intelligence/php-cve-2026-109618-faq/" rel="noopener noreferrer"&gt;watchTowr&lt;/a&gt; adds two points: there is "no official release" for those three branches, and PHP 7.3 and older have no fix available but are confirmed affected.&lt;/p&gt;

&lt;p&gt;So the state of the world is this. 7.4, 8.0 and 8.1 can be patched if someone builds or ships a backport. 7.3 and below can't be patched by anyone except a paid extended support vendor.&lt;/p&gt;

&lt;p&gt;I'll admit that last part annoys me. Plesk's article recommends TuxCare Extended Lifecycle Support for the unpatched branches, which is a reasonable thing to mention, but it also means the fix for abandoned PHP is a subscription. Whether that's worth paying depends on how long you plan to stay on the old version, and I'd put a hard date on it before signing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the version number lies
&lt;/h2&gt;

&lt;p&gt;Here's the trap. The vulnerable 8.1.34 and the patched 8.1.34 print the same thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# example output&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
PHP 8.1.34 &lt;span class="o"&gt;(&lt;/span&gt;cli&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;built: Oct 21 2026 08:12:41&lt;span class="o"&gt;)&lt;/span&gt; NTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that build line says October 21, 2026 or later, you have the fix. If it says anything earlier, you don't, even though the version looks identical. Plesk's article describes the same check: the patched 8.3 build should report 8.3.36 with a build date of Oct 21 2026, and EOL branches should be checked by build date for the same reason.&lt;/p&gt;

&lt;p&gt;Distro packages make this messier, because Debian and Ubuntu and RHEL-family vendors backport security fixes without bumping the upstream version. Rapid7 notes this too: its authenticated Linux package checks recognize backported fixes that keep the original version number. If you're on a distro package, the build date in &lt;code&gt;php -v&lt;/code&gt; may not be a reliable signal. What I'd check instead is whether the vendor's package changelog mentions the CVE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
apt changelog php8.1-cli 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m3&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CVE-2026-109618|CVE-2026-109619'&lt;/span&gt;

&lt;span class="c"&gt;# RHEL / Alma / Rocky&lt;/span&gt;
rpm &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--changelog&lt;/span&gt; php-fpm | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m3&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CVE-2026-109618|CVE-2026-109619'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I should flag that this is my own check, and it assumes your vendor names the CVE in the changelog, which most do but not all. No match doesn't prove you're vulnerable, and a match tells you the package claims a fix. Treat a missing line as a prompt to read your distro's security tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do for each situation
&lt;/h2&gt;

&lt;p&gt;If you're on 8.1, 8.0 or 7.4 from a distro or hosting provider that is still maintaining it: check the changelog as above, patch, restart the workers, and verify. Then put the real upgrade on the calendar, because the vendor will drop it eventually.&lt;/p&gt;

&lt;p&gt;If you're on 8.1, 8.0 or 7.4 and building from source or using a plain Docker tag: you'll need the project's source backports or a newer tag. A Docker image tagged &lt;code&gt;php:8.1-fpm&lt;/code&gt; that was last pulled before the 21st may still be sitting on a pre-fix build, and an old cached layer on your server is easy to forget. Pull again and read the build line instead of trusting the tag.&lt;/p&gt;

&lt;p&gt;If you're on 7.3 or older: there's no patch. Huntress, in its &lt;a href="https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities" rel="noopener noreferrer"&gt;rapid response write-up&lt;/a&gt;, suggests taking end-of-life PHP off the internet, or putting it behind a VPN or WAF. It also suggests adding &lt;code&gt;session.upload_progress.enabled = Off&lt;/code&gt; to &lt;code&gt;php.ini&lt;/code&gt; and restarting, which it says mitigates the known abuse cases. That's a partial measure for one attack path, not a fix.&lt;/p&gt;

&lt;p&gt;Inventory the bundled copies too. Huntress specifically advises finding PHP installed inside vendor software. A control panel, a CMS installer or an old billing tool can carry its own PHP binary that your system &lt;code&gt;php -v&lt;/code&gt; never reveals. Plesk is a good example: its UI component needs a separate update (Obsidian 18.0.80 Update 11 or 18.0.81 Update 4) on top of the PHP builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  A realistic route off the old branch
&lt;/h2&gt;

&lt;p&gt;Patching a backport buys time, nothing more. The real answer to PHP 7.4 end of life, or PHP 8.1 end of life for that matter, is moving to a supported branch, and the sooner the smaller the jump.&lt;/p&gt;

&lt;p&gt;My routine for a client app like the invoicing one is boring. I clone the production code into a branch, run it under the newest supported PHP in a container with the test suite and a static analyser, and read the list of deprecations it produces before I touch a line. Most of the pain is a few dozen deprecated calls and one or two libraries that need a major version bump. The first run is always uglier than the second.&lt;/p&gt;

&lt;p&gt;I do it in steps, not one leap. I'd rather ship 7.4 to 8.1 this month with the new build date confirmed, then 8.1 to 8.3 next quarter, than hold a six-month branch open that nobody wants to merge. While the app stays on the old branch, I keep it off the public internet where I can, or behind the proxy request limits I set up in my &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first" rel="noopener noreferrer"&gt;VPS hardening notes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The argument I'd make to my client
&lt;/h2&gt;

&lt;p&gt;"It works" is a statement about yesterday. For the 7.4 app, I can now say three concrete things.&lt;/p&gt;

&lt;p&gt;The branch has had no routine upstream updates for a long time. A critical unauthenticated bug just landed that it can only get through a special backport. And the cheapest realistic option for 7.3 and older is paid support.&lt;/p&gt;

&lt;p&gt;I'd also point at the cost side: the longer a branch sits unsupported, the bigger the jump becomes. I wrote about planning those windows in my &lt;a href="https://abrarqasim.com/blog/laravel-13-release-date-support-window-i-plan-client-upgrades-around" rel="noopener noreferrer"&gt;Laravel 13 support window post&lt;/a&gt;, and the same logic applies to plain PHP. A jump from 7.4 to 8.3 breaks more than 8.1 to 8.3 does.&lt;/p&gt;

&lt;p&gt;If you want help scoping an upgrade like that, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; has what I've shipped and how to reach me.&lt;/p&gt;

&lt;p&gt;This week, run &lt;code&gt;php -v&lt;/code&gt; on every server and container and write down the build date next to each version. Anything on 7.3 or older, or on 7.4, 8.0 or 8.1 with a build date before October 21, goes to the top of the list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/php-eol-php2shell-build-date-check-for-old-branches/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>phpeol</category>
      <category>php2shell</category>
    </item>
    <item>
      <title>PHP2Shell Explained: The Two Bugs and Who Is Really Exposed</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:39:11 +0000</pubDate>
      <link>https://dev.to/abyzgenic/php2shell-explained-the-two-bugs-and-who-is-really-exposed-4eid</link>
      <guid>https://dev.to/abyzgenic/php2shell-explained-the-two-bugs-and-who-is-really-exposed-4eid</guid>
      <description>&lt;p&gt;I read the PHP2Shell advisory twice and still got one thing wrong. I assumed "unauthenticated RCE in PHP" meant a bug in some function I could grep my codebase for. It doesn't. Neither bug lives in a function you call. They live in what PHP does when a request goes badly, and that is why this pair of CVEs has people more rattled than the usual patch-Tuesday noise.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: CVE-2026-109618 and CVE-2026-109619 are both memory-safety bugs in PHP's request lifecycle, both scored 9.2 on CVSS v4.0, and both are fixed in 8.5.12, 8.4.27, 8.3.36 and 8.2.35. If you want the step-by-step patch routine, I wrote that already in my &lt;a href="https://abrarqasim.com/blog/php-8-5-12-security-update-rce-patch-checklist-php-fpm-mod-php" rel="noopener noreferrer"&gt;PHP 8.5.12 patch checklist&lt;/a&gt;. This post is the other half: what the bugs actually are, and which of your servers are really in the blast radius.&lt;/p&gt;

&lt;p&gt;I haven't reproduced either bug and I'm not going to. Everything below comes from the vendor advisories, and I'll say where I'm inferring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first bug: PHP runs out of memory at a bad moment
&lt;/h2&gt;

&lt;p&gt;CVE-2026-109618 is an out-of-memory problem. According to &lt;a href="https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/" rel="noopener noreferrer"&gt;Rapid7's write-up&lt;/a&gt;, when an allocation fails while PHP is setting up request data, PHP abandons the setup partway through. The request structures are then half initialized. Cleanup code later treats them as if they were fine, and that is the path to code execution.&lt;/p&gt;

&lt;p&gt;The part that surprised me is how the attacker gets PHP to run out of memory. Rapid7 says a crafted multipart request can exhaust memory even when its size looks like it fits inside your configured limits. So "my post_max_size is small" is not the comfort it sounds like.&lt;/p&gt;

&lt;p&gt;Why does this matter more on one setup than another? Because of what happens after the failure. On Apache with mod_php, cleanup still runs even though startup failed, so the demonstrated attack works before any of your application code executes. On PHP-FPM, a failed startup kills the worker, which breaks that particular sequence. Rapid7's source analysis says FPM may still be susceptible after a successful startup, but their advisory reports no complete FPM exploit for this CVE.&lt;/p&gt;

&lt;p&gt;I'd read that as "FPM has a better day than mod_php", not "FPM is safe". The distinction affects how fast you move, and I'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: a request inherits the last one's leftovers
&lt;/h2&gt;

&lt;p&gt;CVE-2026-109619 is a use-after-free across requests. A worker process handles many requests over its life. Some internal state is supposed to be reset between them. In this bug, a pointer survives pointing at memory that was already freed at the end of an earlier request, and a later request on the same worker can read it before initialization clears it.&lt;/p&gt;

&lt;p&gt;What puts the stale pointer there? Fatal errors, mostly. Memory exhaustion and execution timeouts can interrupt the cleanup. Rapid7 also says some non-fatal shutdown diagnostics can repopulate error state after it was reset.&lt;/p&gt;

&lt;p&gt;Two confirmed exploit paths are described. One needs mod_php on PHP 8.5 with &lt;code&gt;fatal_error_backtraces&lt;/code&gt; switched on, which is the default. The other goes through the session upload progress feature, which is also enabled by default. That one affects both mod_php and PHP-FPM, and it can consume the stale state even if your application never calls &lt;code&gt;session_start()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last detail is the one I keep thinking about. Plenty of apps never touch sessions, so the natural reaction is "this doesn't apply to me." It does, because the feature is on at the PHP level, before your code decides anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is actually exposed
&lt;/h2&gt;

&lt;p&gt;Here's the matrix as I understand it, with the caveat that I'm summarizing vendor text.&lt;/p&gt;

&lt;p&gt;All web-serving SAPIs are in scope: PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. The PHP team's own wording, as quoted by &lt;a href="https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities" rel="noopener noreferrer"&gt;Huntress&lt;/a&gt;, is that all PHP installations serving web requests are at risk. CLI-only scripts that never take a web request are a different story, though I'd still patch them because the changelog carries other fixes too.&lt;/p&gt;

&lt;p&gt;mod_php is the highest risk. FPM sits a notch lower for the OOM bug but is fully in play for the session path. If you run a typical Nginx plus FPM stack, you are exposed to 109619 through upload progress and possibly to 109618 after startup.&lt;/p&gt;

&lt;p&gt;Versions: 8.2 before 8.2.35, 8.3 before 8.3.36, 8.4 before 8.4.27 and 8.5 before 8.5.12. Older branches are messier, because for 7.4, 8.0 and 8.1 the patched build carries the same version number as the vulnerable one and only the build date differs.&lt;/p&gt;

&lt;p&gt;Wiz, according to Huntress's summary, demonstrated unauthenticated RCE against WordPress, Drupal and Joomla. That detail matters if you're a freelancer like me. A client's WordPress site on shared hosting is the same bug as my own VPS, with a different person holding the keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a 9.2 score does and doesn't tell you
&lt;/h2&gt;

&lt;p&gt;Both CVEs carry the same CVSS v4.0 vector, &lt;code&gt;AV:N/AC:H/AT:P/PR:N/UI:N&lt;/code&gt;, plus high impact on confidentiality, integrity and availability. Read the first half of that slowly. Network reachable, no privileges, no user interaction. That is the scary part. But &lt;code&gt;AC:H&lt;/code&gt; means high attack complexity and &lt;code&gt;AT:P&lt;/code&gt; means the attack needs specific conditions to be present, such as a particular SAPI or setting.&lt;/p&gt;

&lt;p&gt;So the score is honest about two things at once. The bug is as bad as a bug gets once the conditions line up, and getting the conditions to line up takes work. That matches what Rapid7 found when they tried the public exploits: each one worked for one combination of OS, PHP version and web server and fell over elsewhere.&lt;/p&gt;

&lt;p&gt;I find that more useful than the headline number. It tells me the risk is not evenly spread across my servers. A stock mod_php box on a distro I can name is the worst case. A containerized FPM pool with upload progress disabled is a much harder target. I'd still patch both, but I know which one gets my evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the exploit situation looks like
&lt;/h2&gt;

&lt;p&gt;I want to be careful here because the story changed several times in a week.&lt;/p&gt;

&lt;p&gt;PHP shipped the fixes on October 21, a day earlier than planned after details leaked on the 20th. By the 23rd &lt;a href="https://securitylabs.datadoghq.com/articles/php2shell-critical-vulnerabilities-in-php/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt; noted working public proofs of concept. Rapid7's testing found that each public exploit works only for a specific combination of OS, PHP version and web server, and that none looked broadly reliable. They haven't developed a general exploit themselves.&lt;/p&gt;

&lt;p&gt;That is a useful corrective to the panic, and a poor reason to wait. A bug that needs a tailored exploit per environment is still a bug that people with a lot of free time will tailor. And &lt;a href="https://watchtowr.com/intelligence/php-cve-2026-109618-faq/" rel="noopener noreferrer"&gt;watchTowr&lt;/a&gt; reported seeing exploitation through its honeypot network, so "nobody is trying" is not a safe assumption. Watching the timeline unfold is its own lesson, and I'd rather you learn it from this post than from an incident ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do in the next hour
&lt;/h2&gt;

&lt;p&gt;Pick the box with mod_php first. If you have one, patch it before anything else, then FPM boxes that take public uploads, then the rest.&lt;/p&gt;

&lt;p&gt;Check what is really running, because the package version and the running process can disagree. A restart is part of the fix, since old workers keep running old code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
php-fpm8.4 &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,lstart,cmd | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'php-fpm|php-cgi|frankenphp|lsphp'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a worker's start time is before your upgrade, it is still the old binary. Restart the service, then run that last line again.&lt;/p&gt;

&lt;p&gt;Then do the one thing that costs nothing while you wait for a maintenance window: set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; in php.ini or your FPM pool, not through &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt;, and restart. The advisory is explicit that those two apply too late. It is a partial measure for one bug, so treat it as a seatbelt.&lt;/p&gt;

&lt;p&gt;I maintain PHP servers for clients (there's more on that on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;), and the order for a bug like this is always inventory, patch, restart, verify. If you want a baseline for the rest of the box, my &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first" rel="noopener noreferrer"&gt;VPS hardening notes&lt;/a&gt; cover the stuff I set up before PHP is even installed. This week, run that inventory command on every server you own before you go to bed, and write down which ones still show an old worker start time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/php2shell-explained-two-php-bugs-and-who-is-really-exposed/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>php2shell</category>
      <category>cve</category>
    </item>
    <item>
      <title>PHP Vulnerability Today: What Waiting on the Patch Costs</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:39:13 +0000</pubDate>
      <link>https://dev.to/abyzgenic/php-vulnerability-today-what-waiting-on-the-patch-costs-2ppk</link>
      <guid>https://dev.to/abyzgenic/php-vulnerability-today-what-waiting-on-the-patch-costs-2ppk</guid>
      <description>&lt;p&gt;Confession: when the PHP advisory landed last Wednesday, my first instinct was to schedule the update for the weekend. Critical bug, sure, but I'd read "high attack complexity" in the score and told myself I had time. Then I lined up the dates for the six days that followed, and the weekend plan looked a lot worse.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: waiting on CVE-2026-109618 and CVE-2026-109619 costs you more each day than the score suggests, because the attack side moved faster than the advisory implied. Below is the timeline as I can source it, what a late patch actually risks, and what I'd check if you already waited.&lt;/p&gt;

&lt;p&gt;I'm a developer who runs PHP for clients, not an incident responder. Where I'm guessing, I'll say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline, with sources
&lt;/h2&gt;

&lt;p&gt;Here's what I can pin to a source, in order.&lt;/p&gt;

&lt;p&gt;On October 20, a post about two critical PHP bugs appeared on Hacker News and was removed, but screenshots spread. According to &lt;a href="https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities" rel="noopener noreferrer"&gt;Huntress&lt;/a&gt;, watchTowr confirmed the leak, and the planned Thursday release was pulled forward to Wednesday, October 21.&lt;/p&gt;

&lt;p&gt;On the 21st, PHP shipped 8.5.12, 8.4.27, 8.3.36 and 8.2.35. The same day, &lt;a href="https://watchtowr.com/intelligence/php-cve-2026-109618-faq/" rel="noopener noreferrer"&gt;watchTowr&lt;/a&gt; says its honeypot network observed exploitation. Huntress, checking its own partner systems that afternoon, saw none. Both statements can be true. A honeypot sees the whole internet; a partner base sees a slice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://securitylabs.datadoghq.com/articles/php2shell-critical-vulnerabilities-in-php/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt; notes that working public proofs of concept were circulating by October 23. &lt;a href="https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/" rel="noopener noreferrer"&gt;Rapid7&lt;/a&gt; tested them and found each one works only for a specific mix of OS, PHP version and web server. They had not seen confirmed exploitation themselves but did report multiple attempts.&lt;/p&gt;

&lt;p&gt;On October 26, watchTowr updated its FAQ to say CISA added CVE-2026-109618 to the Known Exploited Vulnerabilities catalog. The page lists KEV status only for that CVE, not for 109619.&lt;/p&gt;

&lt;p&gt;That's five days from fix to the government's "this is being used" list. I'd have bet on longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  One claim I'd throw out
&lt;/h2&gt;

&lt;p&gt;Palo Alto's Unit 42 published a brief on the 23rd linking some post-compromise activity to PHP2Shell, then retracted that link on October 25. Per &lt;a href="https://unit42.paloaltonetworks.com/cve-2026-109618-cve-2026-109619/" rel="noopener noreferrer"&gt;their updated page&lt;/a&gt;, they had seen an attack on a European government agency running a vulnerable PHP application but could not confirm the initial access involved these CVEs.&lt;/p&gt;

&lt;p&gt;I mention this because a lot of people screenshotted the first version. If someone tells you "attackers are dropping X after PHP2Shell", ask which source, and check whether it's the retracted one. The honest state of the evidence is: exploitation attempts, some observed exploitation per watchTowr, a KEV listing for one CVE, and no clean public case study of a full compromise tied to these bugs.&lt;/p&gt;

&lt;p&gt;That's not comfort. It means we're early.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a late patch actually risks
&lt;/h2&gt;

&lt;p&gt;Let me be concrete about the downside, because "critical RCE" is abstract enough that it stops registering.&lt;/p&gt;

&lt;p&gt;These are unauthenticated bugs. PHP's own description, passed along by Huntress, is that an exploit exists for mod_php where an unauthenticated request triggers the attack before any application code runs. There's no login to brute-force, no form to find, and your WAF rules written against your own routes sit in the wrong place.&lt;/p&gt;

&lt;p&gt;Once someone has code execution inside a PHP worker, they have whatever that worker has. Database credentials from your &lt;code&gt;.env&lt;/code&gt;. API keys. Write access to the web root. Wiz showed, again via Huntress's summary, that the memory disclosure side can expose passwords, tokens, API keys and certificates. So the cost of waiting is not only "they take the server". It's "they take everything the server can reach".&lt;/p&gt;

&lt;p&gt;For a freelancer, that last part is the one that wakes me up. One compromised client box can hold the Stripe key, the mail credentials and the database for a business that never heard of CVSS. I keep thinking about how I'd write that email.&lt;/p&gt;

&lt;p&gt;Also remember the shared-hosting case. If you run client sites on one box, one vulnerable pool puts them all in range together.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you already waited, check this
&lt;/h2&gt;

&lt;p&gt;Patching does not undo anything that happened before the patch. If your server was reachable on the 21st and ran an old build for days, spend twenty minutes on these.&lt;/p&gt;

&lt;p&gt;Look for PHP workers spawning things they shouldn't. Florian Roth, quoted by Huntress, expects exploitation to show up as child processes of PHP such as &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt; or &lt;code&gt;nc&lt;/code&gt;. Huntress also flags reverse-shell strings like &lt;code&gt;/dev/tcp/&lt;/code&gt; and &lt;code&gt;fsockopen&lt;/code&gt;, and quick recon commands such as &lt;code&gt;whoami&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PHP workers with suspicious children (rough check, run as root)&lt;/span&gt;
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,user,lstart,cmd &lt;span class="nt"&gt;--forest&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-B1&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'sh -c|bash|curl|wget|nc '&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'php-fpm|php-cgi|apache2|httpd'&lt;/span&gt;

&lt;span class="c"&gt;# files changed in the web root since the fix shipped&lt;/span&gt;
find /var/www &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-newermt&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-20'&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/cache/*'&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/node_modules/*'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;

&lt;span class="c"&gt;# cron entries and recently modified system accounts&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/cron.d /var/spool/cron&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null
lastlog | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are my rough versions of the checks Huntress describes: webshells, new files in the web root, new cron jobs, new accounts. A hit isn't proof and a clean result isn't proof. The Rapid7 advisory itself says the absence of relevant log entries does not prove an installation is safe, and there are no published indicators to match against.&lt;/p&gt;

&lt;p&gt;If something does look off, Huntress's advice is to isolate the server and preserve the web server and PHP logs before touching anything. Then rotate every secret that box could read, not just the obvious ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which servers I'd patch first when I can't do all of them
&lt;/h2&gt;

&lt;p&gt;If you have ten servers and one evening, the order matters more than the speed. This is the order I'd use, and it's my own judgment, not vendor guidance.&lt;/p&gt;

&lt;p&gt;First, anything running Apache with mod_php. The PHP team rates it highest risk because the demonstrated attack lands before application code runs. Second, anything public that accepts file uploads or large multipart posts, since the memory exhaustion path in the first bug starts with a crafted multipart request. Third, anything holding secrets for other systems: a box with payment keys, mail credentials or database admin passwords is worth more to an attacker than a brochure site.&lt;/p&gt;

&lt;p&gt;Last, the internal tools and staging boxes. They matter, but nobody is spraying the internet for your staging server.&lt;/p&gt;

&lt;p&gt;This is also where the weekend plan falls apart. A Saturday window sounds fine until you add up what the timeline above shows: fix released Wednesday, exploitation reported the same day by one source, public exploits by Friday. By Saturday you would have spent a day and a half on a bug with working public exploits and no log evidence either way, and the KEV listing was only two days further on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do about the delay itself
&lt;/h2&gt;

&lt;p&gt;Delays happen for boring reasons. A client is mid-launch. A deployment pipeline is flaky. The distro repo hasn't got the package yet; Huntress notes the Remi repository had no updated packages on release day.&lt;/p&gt;

&lt;p&gt;When I can't patch, I narrow the exposure rather than shrug. Disable session upload progress in the main config, cut the upload size, and put a stricter limit on request bodies at the proxy. That's a partial step for one attack path, and per watchTowr's reading of the PHP statement, no configuration change fully mitigates these bugs. If the app is public and the stakes are real, a maintenance window tonight beats a perfect one on Saturday. My &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first" rel="noopener noreferrer"&gt;VPS hardening baseline&lt;/a&gt; has the proxy and firewall pieces I use to narrow things down.&lt;/p&gt;

&lt;p&gt;If you want to see how I handle this kind of work for clients, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; has the details.&lt;/p&gt;

&lt;p&gt;Tonight, pick your most exposed server, run the first command above, and if anything unexpected shows up, isolate the box before you do anything else.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/php-vulnerability-today-what-waiting-on-the-patch-costs/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>cve</category>
      <category>incidentresponse</category>
    </item>
    <item>
      <title>Kimi K3.1 Pro Pricing: The Per-Task Math Before You Switch</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:39:09 +0000</pubDate>
      <link>https://dev.to/abyzgenic/kimi-k31-pro-pricing-the-per-task-math-before-you-switch-1pom</link>
      <guid>https://dev.to/abyzgenic/kimi-k31-pro-pricing-the-per-task-math-before-you-switch-1pom</guid>
      <description>&lt;p&gt;Sticker prices for LLM APIs have misled me enough times that my first move on any new model is to convert per-million-token prices into cost per finished task. Kimi K3.1 Pro showed up this week, and the sticker looks good, so here's that conversion.&lt;/p&gt;

&lt;p&gt;Short version: at the listed rate, a coding-agent task I'd call typical costs about $3 uncached. That's real money saved against a pricier model if the quality holds on your workload, and it's a number you can only trust after you've read the caching rules and run your own tasks.&lt;/p&gt;

&lt;p&gt;I haven't called the Kimi API myself. Everything below comes from &lt;a href="https://simonwillison.net/2026/Oct/23/kimi-k31-pro/" rel="noopener noreferrer"&gt;Simon Willison's write-up&lt;/a&gt; and Moonshot's own &lt;a href="https://platform.moonshot.ai/docs/pricing/chat" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;, and I'll say which is which as I go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually on the page
&lt;/h2&gt;

&lt;p&gt;Simon's post, dated 23 October, gives these facts. Kimi K3.1 Pro landed on the Kimi API that day. It isn't open weights yet, but Moonshot promise full model weights by 4 November 2026. There's a "Highspeed" version that's two to three times faster at twice the price, except that input tokens served from cache cost the same as on the standard version. The dollar pricing is $2.00 per million input tokens and $10 per million output tokens, which Simon says matches GPT-6.1 Sol and is half the price of Claude Opus 5.5.&lt;/p&gt;

&lt;p&gt;Moonshot's pricing page adds a few details that matter more than the headline. All K3 series models support a context window of 1,048,576 tokens. Caching is automatic for repeated request prefixes with a default TTL of five minutes. For K3 models, cache writes are billed separately by TTL (five minutes or one hour), and cached input is billed at the cache-hit price only, with no extra charge for the write. Pricing is pay-as-you-go with no subscription, and the page says that differs from the Kimi membership and Kimi Code products.&lt;/p&gt;

&lt;p&gt;The same page carries a migration notice. From 31 October 2026, K2 series models stop being available to newly registered users, and Moonshot plan to retire all K2 models by the end of December 2026. If you have anything still pointed at K2, that date is yours to deal with regardless of what you think of K3.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost per task, not cost per token
&lt;/h2&gt;

&lt;p&gt;Per-million-token prices don't tell you what a job costs, because agents don't make one call. They loop. Every step re-sends the growing conversation, so input tokens pile up far faster than output tokens.&lt;/p&gt;

&lt;p&gt;Here's a task shape I use for back-of-envelope work. An agent makes 40 model calls to finish a task. Each call sends about 30,000 tokens of context (files, tool output, history) and gets back about 1,500 tokens. Those numbers are made up, but they're close to what a medium coding task looks like, and you should replace them with measurements from your own logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;task_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;cached_share&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cached_price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Prices are dollars per million tokens. cached_share is the
    fraction of input tokens served from cache (0 to 1).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;total_in&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;in_tok&lt;/span&gt;
    &lt;span class="n"&gt;total_out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;out_tok&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached_price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cached_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;in_price&lt;/span&gt;  &lt;span class="c1"&gt;# unknown: assume no discount
&lt;/span&gt;    &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_in&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cached_share&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_in&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cached_share&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;in_price&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cached_price&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;total_out&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;out_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;

&lt;span class="c1"&gt;# Kimi K3.1 Pro at the listed standard rate, no cache discount assumed
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;task_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 3.0
# Highspeed at twice the price, same assumption
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;task_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 6.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 1.2 million input tokens and 60,000 output tokens per task. Input costs $2.40 and output costs $0.60, so $3.00. If Simon's comparison holds and the Opus 5.5 rate is double, the same task comes to $6.00 there, and the Highspeed tier lands at the same $6.00. Multiply by 500 tasks a month and you get $1,500 against $3,000.&lt;/p&gt;

&lt;p&gt;Two things to take from the arithmetic. First, input dominates. Output is 20 percent of the bill in this shape, so a model that talks less doesn't save you as much as one that re-reads less. Second, the 2x speed premium brings the cost to about what Opus 5.5 would be by Simon's comparison, so Highspeed is a latency purchase and a poor way to save money. Buy it for interactive use, where a person waits on the answer, and keep batch jobs on the standard tier.&lt;/p&gt;

&lt;p&gt;I wrote a longer version of this sort of math, with cached-token pricing across several vendors, in &lt;a href="https://abrarqasim.com/blog/llm-pricing-comparison-cached-token-math-opus-5-5-gpt-6-grok" rel="noopener noreferrer"&gt;my LLM pricing comparison&lt;/a&gt;. The shape is the same: model the loop, not the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the caching rules before you trust the number
&lt;/h2&gt;

&lt;p&gt;The $3.00 above assumes zero cache benefit, and the real figure is probably lower. I say "probably" because I don't have the cache-hit price in front of me. Simon's post says Highspeed cache hits cost the same as standard, and Moonshot's billing text refers to a cache-hit price, but I haven't seen the number, and the Moonshot page I fetched didn't render its price table without JavaScript. Check the live table before you rely on any figure here.&lt;/p&gt;

&lt;p&gt;What I can say from the billing text is how the mechanism behaves, and that matters because it decides whether your agent benefits at all. Caching applies to repeated request prefixes. An agent that appends to a conversation sends the same prefix on every step, so it should hit. An agent that rewrites a system prompt each step, injects a timestamp near the top, or reorders its tool list will miss every time. With a five-minute default TTL, a pipeline that pauses for human review longer than that likely pays full price again after the pause.&lt;/p&gt;

&lt;p&gt;So the work is mechanical. Put stable content first and variable content last. Keep timestamps out of the prefix. If your tasks pause for long gaps, look at the one-hour TTL option and whether its write pricing is worth it for your pattern. Then send 20 real tasks and read the usage numbers the API returns, because the sticker never tells you your cache hit rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The open-weights promise is a date, not a model
&lt;/h2&gt;

&lt;p&gt;The headline that will get shared is that a model near the top of the benchmarks is about to be open weights. Moonshot's own statement is that weights will be released by 4 November. That's a little over a week away, and a promise isn't a download.&lt;/p&gt;

&lt;p&gt;Here's why it matters for planning. If you're thinking of self-hosting, you can't size hardware, pick an inference server, or check the licence until the files exist. The licence terms decide whether you can use the model commercially for a client, and those are not something I'd assume from the word "open". Context length matters here too. A 1,048,576-token window is great for the API and may cost a lot of memory to serve yourself, so the context you can actually afford on your own hardware may be far smaller than the number on the page.&lt;/p&gt;

&lt;p&gt;My advice is dull: don't build anything that depends on self-hosting K3.1 Pro until the weights are public and you've read the licence. Using the hosted API in the meantime costs you nothing but the data-handling question, and that one is worth a conversation with the client before you send their code to a vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  One benchmark score isn't your workload
&lt;/h2&gt;

&lt;p&gt;Simon reports that Moonshot's own benchmarks put Kimi roughly level with Opus 5.5 and GPT-6 Astra, and that Artificial Analysis scored it 55. By his account, that's ahead of Claude Fable 5.1, GPT-6 Astra and Gemini 4 Argon at 53 each, and behind Claude Opus 5.5 at 58. He also notes this is the first time a soon-to-be open-weight model has scored this high against the leaders.&lt;/p&gt;

&lt;p&gt;I take that seriously, and I still wouldn't switch anything on a three-point gap on an aggregate index. An aggregate averages over tasks you may never run. A model that's two points lower overall can be better on your tool-calling format, your language, or your repo's size. The reverse is just as likely. It's also worth remembering that the pelican-on-a-bicycle test Simon uses is a fun smoke test of reasoning levels, and not a coding evaluation.&lt;/p&gt;

&lt;p&gt;The check I trust is small and boring. Take 20 tasks from your real backlog, run them through the current model and the candidate with the same harness, and compare pass rate and cost per passing task. Cost per task divided by pass rate is the number that matters. At $3 per task and a 40 percent pass rate, a model costs $7.50 per success. At $6 and 90 percent, the pricier one costs $6.67. The cheaper sticker loses, and you'll only find that out by running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do this week
&lt;/h2&gt;

&lt;p&gt;Pull usage logs from your current agent setup and compute your real average calls, input tokens and output tokens per task. Plug them into the function above with the standard rates. If the answer is a large saving, pick 20 representative tasks and run them on K3.1 Pro through the same harness you use today. Record pass rate, total cost and cache-hit rate, and decide from those.&lt;/p&gt;

&lt;p&gt;Set a calendar reminder for 4 November to check whether the weights shipped, and read the licence the day they do. If you have anything on a K2 model, schedule that migration before the end of December. If you want to see how I wire agents into client workflows, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; has the projects.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/kimi-k3-pricing-k3-1-pro-per-task-math-before-you-switch/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kimi</category>
      <category>llmpricing</category>
      <category>aiagents</category>
      <category>moonshot</category>
    </item>
    <item>
      <title>Alternatives to Tailwind CSS: Name the Complaint First</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:39:08 +0000</pubDate>
      <link>https://dev.to/abyzgenic/alternatives-to-tailwind-css-name-the-complaint-first-2cig</link>
      <guid>https://dev.to/abyzgenic/alternatives-to-tailwind-css-name-the-complaint-first-2cig</guid>
      <description>&lt;p&gt;I defend Tailwind more than I probably should, which makes me a biased judge of its alternatives. So this post is me trying to write down the honest case for leaving, then checking which of the alternatives actually survive it.&lt;/p&gt;

&lt;p&gt;Short version: most people who search for alternatives to Tailwind CSS don't need a different framework. They need to know which of four complaints they have, because each one has a different fix, and two of the fixes are plain CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complaints I'd take seriously
&lt;/h2&gt;

&lt;p&gt;Tailwind complaints tend to come bundled together, and they shouldn't be. Pulled apart, I hear four of them.&lt;/p&gt;

&lt;p&gt;The first is markup noise. A card with eleven classes on the wrapper and six on every child is hard to scan, and it's hard to review in a pull request when the real change is one class in the middle of a long string. This one is legitimate. Components help, but only if you actually extract them.&lt;/p&gt;

&lt;p&gt;The second is the build step. Tailwind needs a toolchain, and some projects, a marketing page with one stylesheet, a server-rendered app with no bundler, don't have one and don't want one.&lt;/p&gt;

&lt;p&gt;The third is team taste. Some teams read utility classes fluently and some never will. If half your team resents the syntax, "it's faster once you learn it" is a weak argument against a reviewer who is still not fluent two years in.&lt;/p&gt;

&lt;p&gt;The fourth is lock-in to a vocabulary. &lt;code&gt;px-4&lt;/code&gt; means something in Tailwind and nothing anywhere else. Your design tokens live in Tailwind's theme, and moving them out takes real work.&lt;/p&gt;

&lt;p&gt;Notice what's missing from that list: performance, and "it's not real CSS". Neither holds up well. The output is a stylesheet, and the &lt;a href="https://tailwindcss.com/docs/theme" rel="noopener noreferrer"&gt;theme variables&lt;/a&gt; in current versions are ordinary CSS custom properties you declare with &lt;code&gt;@theme&lt;/code&gt;. I've already written about &lt;a href="https://abrarqasim.com/blog/tailwind-v4-in-2026-the-css-first-config-i-actually-ship" rel="noopener noreferrer"&gt;the CSS-first config I actually ship&lt;/a&gt;, so I won't repeat the setup here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native CSS has caught up with a lot of the pitch
&lt;/h2&gt;

&lt;p&gt;Part of why Tailwind took over was that writing plain CSS used to hurt. No nesting without Sass, no scoping, no clean way to share values. Several of those problems got solved in the platform itself.&lt;/p&gt;

&lt;p&gt;CSS nesting is the one that matters most for this comparison. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Nesting/Using" rel="noopener noreferrer"&gt;MDN guide to CSS nesting&lt;/a&gt; describes it as being parsed by the browser instead of compiled ahead of time by a preprocessor like Sass, and it lets you stop repeating the parent selector. Add custom properties for your tokens, &lt;code&gt;@layer&lt;/code&gt; for ordering, and &lt;code&gt;@container&lt;/code&gt; for component-level responsiveness, and a stylesheet with no toolchain starts to look reasonable again.&lt;/p&gt;

&lt;p&gt;Here's the same card both ways. First, the Tailwind version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"rounded-xl border border-zinc-200 bg-white p-6 shadow-sm
                hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-lg font-semibold text-zinc-900 dark:text-zinc-50"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Deploy log&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mt-2 text-sm text-zinc-600 dark:text-zinc-400"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Last run finished clean.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And native CSS with nesting and custom properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--card-bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--card-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#e4e4e7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--card-title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#18181b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--card-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#52525b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--card-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--card-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="err"&gt;&amp;amp;:hover&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;-1px&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nt"&gt;h3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.125rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--card-title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nt"&gt;p&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.875rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--card-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--card-bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#18181b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--card-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#27272a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--card-title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#fafafa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--card-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#a1a1aa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Deploy log&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Last run finished clean.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The markup is cleaner and the CSS is longer. That trade is the whole debate in one screen. I'd rather pay in CSS lines for a component I touch twice a year, and pay in class strings for the page I rearrange every week. Neither choice is a moral one.&lt;/p&gt;

&lt;p&gt;One honest difference: the dark mode above follows the OS setting. A class-based toggle that a user can flip needs one more piece, a selector like &lt;code&gt;:root[data-theme="dark"]&lt;/code&gt; that you maintain yourself. Tailwind gives you that with a variant. It's small, and you'll feel it the first time a client asks for a toggle button.&lt;/p&gt;

&lt;h2&gt;
  
  
  UnoCSS and Panda keep the idea and change the engine
&lt;/h2&gt;

&lt;p&gt;If what you like is the speed of styling in the markup, but you dislike something about Tailwind specifically, two tools keep the approach.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://unocss.dev/guide/" rel="noopener noreferrer"&gt;UnoCSS&lt;/a&gt; describes itself as an instant atomic CSS engine with no opinions in the core, where all the utilities come from presets. You can define your own rule in a config file and it only generates CSS for classes it finds in your code. That flexibility is the pitch. If your team has strong views about utility names, you can write the vocabulary yourself. The cost is that you now own a vocabulary, and the next developer has to learn yours, because they didn't learn it anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://panda-css.com/docs/overview/getting-started" rel="noopener noreferrer"&gt;Panda CSS&lt;/a&gt; goes the other direction. You write styles in TypeScript, and it reads your source at build time to generate atomic CSS, so you get something like CSS-in-JS authoring without shipping a style engine to the browser. The same card looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;css&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../styled-system/css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;rounded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;borderWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;_hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;semibold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Deploy log&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd pick Panda when type safety on style props is the thing the team wants, because a typo in a token name becomes a compile error instead of a card that quietly has no padding. I wouldn't pick it for a content site, because a code generation step and a generated &lt;code&gt;styled-system&lt;/code&gt; folder are a lot of machinery for a blog.&lt;/p&gt;

&lt;p&gt;I haven't shipped either one in production, so my opinion here is a reading of their docs, not a war story. If you've run UnoCSS on a large codebase, your experience is worth more than mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS Modules and vanilla-extract are the boring choice
&lt;/h2&gt;

&lt;p&gt;When the complaint is markup noise and the team is already in a component framework, I'd look at the dullest option first. CSS Modules is a bundler feature: you write a normal stylesheet, import it, and class names become locally scoped. You get nesting from the browser, scoping from the bundler, and no new vocabulary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* card.module.css */&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;-1px&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;styles&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./card.module.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Deploy log&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://vanilla-extract.style/documentation/getting-started/" rel="noopener noreferrer"&gt;vanilla-extract&lt;/a&gt; takes that idea and puts it in TypeScript. You write styles in a &lt;code&gt;.css.ts&lt;/code&gt; file, they compile to a static stylesheet, and you import class names like any other value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// card.css.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vanilla-extract/css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;style&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.5rem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.75rem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:hover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;boxShadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 4px 6px -1px rgb(0 0 0 / 0.1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both give you scoping, which is the thing Tailwind avoids needing in the first place, since utilities don't collide. If your pain is that global CSS leaks, scoping solves it directly. If your pain is that class strings are ugly, scoping does nothing for you and you've swapped one kind of ceremony for another.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I'd stay put
&lt;/h2&gt;

&lt;p&gt;Switching has a cost that the alternatives' docs never mention. Every project already on Tailwind has people who can read it, a design system that's expressed in it, and a pile of snippets, templates and component libraries that assume it. Leaving means you re-express all of that, and the savings only show up if the complaint was real.&lt;/p&gt;

&lt;p&gt;I'd stay with Tailwind when the team is fast in it, when the project has a design system with many contributors, and when the markup noise is something extracted components could fix. I'd also stay when the real problem is that the design itself is inconsistent. A different styling tool doesn't make a team agree on spacing. Writing the spacing scale down as theme values does, and that's a one-afternoon job whichever tool you're using.&lt;/p&gt;

&lt;p&gt;I'd leave, or at least not start, for a small content site with no bundler, for a team that's openly unhappy with the syntax, and for a library whose users shouldn't need your build setup to consume it. Those three have something in common: the cost of the toolchain or the vocabulary lands on someone who didn't choose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd decide this week
&lt;/h2&gt;

&lt;p&gt;Don't start by picking a tool. Start by naming the complaint, because the cheapest fix for each one is different.&lt;/p&gt;

&lt;p&gt;If the complaint is markup noise, extract components before you change tools. Take the five most repeated class strings in your codebase and turn each into a component. If that isn't enough, the native CSS version above is the next thing to try.&lt;/p&gt;

&lt;p&gt;If the complaint is the build step, use plain CSS with nesting and custom properties. For a small site, it's less code to maintain than a config you never touch.&lt;/p&gt;

&lt;p&gt;If the complaint is team taste, ask the team what they'd write instead, and take that answer seriously. A styling system half the team resents will decay no matter how well it benchmarks.&lt;/p&gt;

&lt;p&gt;If the complaint is token lock-in, move your tokens into CSS custom properties first. That's useful whichever tool you end up with, and it's the first step Tailwind's own theme variables already point toward.&lt;/p&gt;

&lt;p&gt;This week, take one real component from your project, the card or the button, and rewrite it in native CSS next to the Tailwind version. Time how long it takes and look at which one you'd rather review. If you want to see how I structure client frontends before choosing a stack, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; has examples.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/alternatives-to-tailwind-css-name-the-complaint-before-you-pick-a-tool/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>css</category>
      <category>frontend</category>
      <category>nativecss</category>
    </item>
    <item>
      <title>Oracle to PostgreSQL Migration: The Residue Report I'd Price From</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:39:07 +0000</pubDate>
      <link>https://dev.to/abyzgenic/oracle-to-postgresql-migration-the-residue-report-id-price-from-525p</link>
      <guid>https://dev.to/abyzgenic/oracle-to-postgresql-migration-the-residue-report-id-price-from-525p</guid>
      <description>&lt;p&gt;Short version for the impatient: before you pick an Oracle to PostgreSQL migration tool, ask it for the list of things it refuses to convert. That list is your quote. The converted DDL is the cheap part.&lt;/p&gt;

&lt;p&gt;I read the &lt;a href="https://www.postgresql.org/about/news/pgrecon-open-source-oracle-to-postgresql-migration-assessment-and-schema-conversion-3410/" rel="noopener noreferrer"&gt;pgrecon announcement on the PostgreSQL news page&lt;/a&gt; twice this week. The first read was for the feature list. The second was because the benchmark table made me suspicious, and I wanted to work out whether the suspicion was fair. I haven't run pgrecon against a real Oracle estate, so treat everything below as a reading of what the project claims plus my own habits from client migrations, not a test report.&lt;/p&gt;

&lt;p&gt;If you quote fixed-price database moves for clients, or you're the person who gets handed one, this is the part of the problem I'd look at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the schema script is the cheap part
&lt;/h2&gt;

&lt;p&gt;Every Oracle migration pitch starts with a demo where tables go in one side and come out the other. Tables are easy. A table is columns, types and constraints, and the mapping is mostly mechanical. The money disappears somewhere else: PL/SQL packages that keep state between calls, triggers that write audit rows in their own transaction, jobs scheduled inside the database, and application code that quietly depends on how Oracle treats empty strings.&lt;/p&gt;

&lt;p&gt;That last one deserves a paragraph because it bites people who think they've finished. Oracle treats an empty string as NULL. PostgreSQL does not. A column that has always been "optional" in practice, with the application sending an empty string, behaves one way on Oracle and another on PostgreSQL. Your &lt;code&gt;WHERE col IS NULL&lt;/code&gt; filters, your &lt;code&gt;NOT NULL&lt;/code&gt; constraints and your unique indexes all change meaning without a single syntax error. No DDL converter can fix that. Only an assessment that goes looking for it can.&lt;/p&gt;

&lt;p&gt;So the useful question for any tool is not "how much did it convert?" It's "how clearly did it tell me what it left behind?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What pgrecon says it does
&lt;/h2&gt;

&lt;p&gt;The project describes three stages. Your DBA runs one read-only SQL*Plus script and sends back the spool files. The tool loads them into a local SQLite inventory and never connects to either database. Then a rule engine of 80 rules runs over the inventory, and a converter emits PostgreSQL DDL for what it can prove equivalent.&lt;/p&gt;

&lt;p&gt;Two design choices stood out to me.&lt;/p&gt;

&lt;p&gt;The first is the offline dump. I like this more than I expected to. A lot of Oracle estates belong to companies where nobody will hand a contractor a connection string in week one. A short, auditable script that a DBA reads line by line and runs with a read-only account is a much easier yes. The &lt;a href="https://muzzammil242.github.io/pgrecon" rel="noopener noreferrer"&gt;project page&lt;/a&gt; says the script is short enough to audit, and that's the right claim to make, because it's the thing a security reviewer will actually check.&lt;/p&gt;

&lt;p&gt;The second is the residue report. The page says everything the converter can't carry faithfully becomes a named line instead of emitted DDL. Their examples are the sort of thing I'd want to see: a view using &lt;code&gt;JSON_TABLE&lt;/code&gt; that needs a hand rewrite because PostgreSQL 17 added it with different clause syntax, a trigger that runs an autonomous transaction, a compound trigger, an external file locator column with no counterpart.&lt;/p&gt;

&lt;p&gt;Read those four lines again. Each one is a different kind of work. The view is an afternoon. The autonomous transaction is a design decision, because you're choosing between &lt;code&gt;dblink&lt;/code&gt;, a queue, or changing what the audit table means. The compound trigger is a restructure. The file locator is a conversation with whoever owns the files. A flat list with reasons attached is how you turn "it's about six months" into a number you can defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the conversions actually look like
&lt;/h2&gt;

&lt;p&gt;Here's the flavour of what a converter has to get right. Take the classic Oracle top-N pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oracle&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ROWNUM&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The announcement says the converter now turns ROWNUM top-N bounds into &lt;code&gt;LIMIT&lt;/code&gt;. Fine, but notice what the tool has to prove. If somebody wrote the lazy version, &lt;code&gt;WHERE ROWNUM &amp;lt;= 10 ORDER BY salary DESC&lt;/code&gt; with no subquery, Oracle applies the row limit before the sort and returns ten arbitrary rows. A mechanical conversion to &lt;code&gt;LIMIT&lt;/code&gt; would silently start returning the real top ten. That's arguably the behaviour the author wanted, and it's still a change that someone should sign off on. I'd want to know whether the tool flags that shape or just rewrites it.&lt;/p&gt;

&lt;p&gt;Hierarchies are the other one I always check, and the announcement says &lt;code&gt;CONNECT BY&lt;/code&gt; becomes &lt;code&gt;WITH RECURSIVE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oracle&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
&lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;PRIOR&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one is mechanical for simple cases. It gets uglier with &lt;code&gt;CONNECT_BY_ISCYCLE&lt;/code&gt;, &lt;code&gt;SYS_CONNECT_BY_PATH&lt;/code&gt; and &lt;code&gt;ORDER SIBLINGS BY&lt;/code&gt;, none of which the announcement mentions. Absence from a press release isn't evidence of absence from the tool. I'd test exactly those on a sample schema before believing any summary.&lt;/p&gt;

&lt;p&gt;There's also a quieter item in the announcement: both extraction tiers capture character versus byte length semantics, so &lt;code&gt;VARCHAR2(30 CHAR)&lt;/code&gt; maps to &lt;code&gt;varchar(30)&lt;/code&gt;. This matters because PostgreSQL's &lt;code&gt;varchar(n)&lt;/code&gt; counts characters. A &lt;code&gt;VARCHAR2(30 BYTE)&lt;/code&gt; column on a UTF-8 database holds fewer characters than the same number in PostgreSQL. Moving to the looser limit is usually harmless. Moving data the other direction later, if you keep Oracle as a fallback during cutover, is where it hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a benchmark the vendor ran
&lt;/h2&gt;

&lt;p&gt;Now the part that made me suspicious. The project page has a table of apply errors against a live PostgreSQL 16 after converting nine schemas. pgrecon shows zero. The other tools show 40, 58, 77, 135 and 481, depending on the tool, and the page adds notes such as "PL/SQL out of scope by design" and "tables, indexes and sequences only".&lt;/p&gt;

&lt;p&gt;I'm not saying the numbers are wrong. The page says the benchmark workflow lives in the repository and is re-run in CI, which is exactly how this should be published, and I'd rather see a reproducible vendor benchmark than none at all. But I'd hold three reservations.&lt;/p&gt;

&lt;p&gt;One, the author picked the schemas. Oracle's HR, OE and CO samples plus a handful of open-source PL/SQL libraries are a sensible public set, and they're also a set the author could tune against for months. Your estate isn't in it.&lt;/p&gt;

&lt;p&gt;Two, "apply errors" counts statements PostgreSQL rejected. It doesn't count statements PostgreSQL accepted that now behave differently. A tool that declines hard cases gets a perfect score on this metric by design, and the page is open about that. Declining is the honest behaviour, and it still isn't the same thing as converting.&lt;/p&gt;

&lt;p&gt;Three, the comparison tools are measured at specific versions on specific dates, and some of the notes describe scope choices, not defects. The CYBERTEC and Dalibo notes say what they cover. Reading "40 errors" next to "0 errors" without those notes would be unfair to both.&lt;/p&gt;

&lt;p&gt;The version of this that I do trust is the project's own statement of what it declines. A tool that publishes its residue is easier to audit than one that publishes a score.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd run an assessment this week
&lt;/h2&gt;

&lt;p&gt;If I had an Oracle estate on my desk tomorrow, this is the order I'd go in. It's the order that spends the least client money before the first real decision.&lt;/p&gt;

&lt;p&gt;First, get the DBA to run the extraction script on a non-production copy with a read-only account, and read the script yourself before they do. If the client's security team balks at that, you've learned something about the schedule.&lt;/p&gt;

&lt;p&gt;Second, install the tool with &lt;code&gt;pip install pgrecon&lt;/code&gt; and run the report before the converter. Sort findings by severity and read the evidence lines. Don't skim the summary. A line-level hit on package-level state or on database links is worth more than a hundred green rows.&lt;/p&gt;

&lt;p&gt;Third, take the residue report and price each line separately, in days, with a name next to it. If a line says "redesign with a queue", that's a design meeting and a test plan, not an hour.&lt;/p&gt;

&lt;p&gt;Fourth, check the empty-string behaviour by hand. Grep the application code for comparisons against &lt;code&gt;''&lt;/code&gt; and for string concatenation over nullable columns. Oracle's &lt;code&gt;||&lt;/code&gt; treats NULL as an empty string, and PostgreSQL's returns NULL, so &lt;code&gt;first_name || ' ' || middle_name&lt;/code&gt; quietly starts blanking whole names. A schema dump can't show you that.&lt;/p&gt;

&lt;p&gt;Fifth, apply the converted DDL to a scratch PostgreSQL instance yourself. Don't take the apply-error count from anyone's table, including mine. If you want the data-movement side of a database move, I wrote about a MySQL to PostgreSQL one in &lt;a href="https://abrarqasim.com/blog/postgresql-migrator-1-0-mysql-to-postgresql-the-row-that-kept-my-write-freeze" rel="noopener noreferrer"&gt;the row that kept my write freeze&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you take on this kind of work, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; lists the database and backend projects I do for clients. For partitioned tables in particular, read the &lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;PostgreSQL partitioning chapter&lt;/a&gt; before you accept any automatic mapping of Oracle interval partitions, because PostgreSQL's declarative partitioning needs partitions to exist up front.&lt;/p&gt;

&lt;p&gt;This week, do one thing: ask whichever tool you're considering to produce its refusal list for a schema you own, and put a day estimate beside every line. If it can't produce the list, you have your answer about the quote.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/oracle-to-postgresql-migration-pgrecon-the-residue-report-i-would-price-from/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>oracle</category>
      <category>databasemigration</category>
      <category>pgrecon</category>
    </item>
    <item>
      <title>Claude Agent Skills Move to the Linux Foundation: What Changes</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:39:02 +0000</pubDate>
      <link>https://dev.to/abyzgenic/claude-agent-skills-move-to-the-linux-foundation-what-changes-4onb</link>
      <guid>https://dev.to/abyzgenic/claude-agent-skills-move-to-the-linux-foundation-what-changes-4onb</guid>
      <description>&lt;p&gt;I have a folder on my machine called skills, and until yesterday I thought of it as a Claude thing. Then Anthropic announced it is donating the Agent Skills standard to the Agentic AI Foundation, and I had to admit I'd never actually read the spec. I'd just copied the shape of the first skill I saw and kept going.&lt;/p&gt;

&lt;p&gt;So I read it. It's short. That turns out to be the whole story, and it's also the reason I'm a little suspicious of the excitement around this news.&lt;/p&gt;

&lt;p&gt;Here's what was announced, what a skill is at the file level, and what I'd do differently now that the standard has a neutral owner. If you want to know who's writing this, I build automation for clients, and you can see the kind of work on my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Anthropic actually announced
&lt;/h2&gt;

&lt;p&gt;Per &lt;a href="https://www.anthropic.com/news/donating-agent-skills-to-the-agentic-ai-foundation" rel="noopener noreferrer"&gt;Anthropic's post from October 22&lt;/a&gt;, the company is donating the Agent Skills standard to the Agentic AI Foundation (AAIF), which sits under the Linux Foundation. Anthropic says it co-founded the AAIF with Block and OpenAI last year, and that it donated the Model Context Protocol to the same place.&lt;/p&gt;

&lt;p&gt;The donation covers more than the text of the spec. Anthropic lists the Agent Skills name and logo, the GitHub organization, and the agentskills.io domain. It also says you can keep using the standard or contribute without joining the AAIF, which matters if you're a solo developer who doesn't want a membership form.&lt;/p&gt;

&lt;p&gt;On the numbers, the post says more than 70 agent platforms have integrated skills since the standard was published in December 2025, and names ChatGPT, Cursor, GitHub Copilot and VS Code. It says 1 million skills have been published and nearly 280 million installed. Those are Anthropic's figures, and I can't verify them. A million skills sounds like a lot until you remember that a skill can be a folder with one short markdown file, so the count says more about how cheap they are to make than how good they are.&lt;/p&gt;

&lt;p&gt;Maintenance goes to engineers from Anthropic, Google, OpenAI and other companies, acting as individuals, with David Soria Parra and Mahesh Murag as lead maintainers. &lt;a href="https://simonwillison.net/2026/Oct/22/donating-agent-skills/" rel="noopener noreferrer"&gt;Simon Willison's short note on the news&lt;/a&gt; points out he guessed this destination last December.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a skill is, file by file
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://agentskills.io/specification" rel="noopener noreferrer"&gt;specification&lt;/a&gt; says a skill is a directory with, at minimum, a &lt;code&gt;SKILL.md&lt;/code&gt; file. Optional folders are &lt;code&gt;scripts/&lt;/code&gt;, &lt;code&gt;references/&lt;/code&gt; and &lt;code&gt;assets/&lt;/code&gt;. That's it. No manifest language, no build step, no registry you must publish to.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SKILL.md&lt;/code&gt; starts with YAML frontmatter. Two fields are required. The &lt;code&gt;name&lt;/code&gt; must be 1 to 64 characters, lowercase letters, numbers and hyphens, no leading or trailing hyphen, no double hyphens, and it has to match the parent directory name. The &lt;code&gt;description&lt;/code&gt; must be 1 to 1024 characters and should say both what the skill does and when to use it. Optional fields are &lt;code&gt;license&lt;/code&gt;, &lt;code&gt;compatibility&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt; and an experimental &lt;code&gt;allowed-tools&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the smallest valid one, close to the spec's own example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pdf-processing&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extracts&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tables&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PDF&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;files,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fills&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PDF&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;forms,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;merges&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;multiple&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PDFs.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;working&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PDF&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;documents&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mentions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PDFs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;forms,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;document&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;extraction."&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# PDF processing&lt;/span&gt;

Steps go here, in plain markdown.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to underline the description field, because it's the part I'd have gotten wrong if I'd kept guessing. The agent doesn't read your whole skill up front. The spec describes progressive disclosure, so the agent sees the name and description first and loads the body when the task seems to match. A vague description like "Helps with PDFs" is, in the spec's own words, a poor example. If the description is lazy, the skill never fires, and you'll blame the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  A description that fires, and one that doesn't
&lt;/h2&gt;

&lt;p&gt;This is the part that pays back fastest, so here's a concrete pair. Say you wrote a skill that turns a client's raw invoice export into a clean CSV.&lt;/p&gt;

&lt;p&gt;The lazy version of the description is "Helps with invoices." Ask yourself when an agent would load that. A user says "tidy up this spreadsheet", and nothing in the description mentions spreadsheets, exports or CSV. The skill sits there unused.&lt;/p&gt;

&lt;p&gt;The better version names the input, the output and the trigger: "Converts a raw invoice export (CSV or XLSX) into a cleaned CSV with one row per line item. Use when the user mentions invoices, billing exports, line items or accounting spreadsheets." It's longer, and it gives the agent words to match against.&lt;/p&gt;

&lt;p&gt;The spec's limit of 1024 characters is generous. I'd use maybe a third of it. Past two or three sentences you start describing the skill's steps, and those belong in the body, which only loads once the skill has been picked.&lt;/p&gt;

&lt;p&gt;Names get less attention but deserve a minute. They must match the directory, so renaming a folder to tidy up your repo can silently break a skill. I'd keep names boring and literal, like &lt;code&gt;invoice-cleanup&lt;/code&gt;, and resist the urge to be clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the spec as a client-work person
&lt;/h2&gt;

&lt;p&gt;Most of my day involves small teams who want one thing automated and don't care about standards bodies. For them, the useful fact is that a skill is a plain folder. It can live in git, get code reviewed, and be copied between machines without a package manager. That's a better story than most plugin systems I've dealt with.&lt;/p&gt;

&lt;p&gt;It also means the same skill can, in principle, travel between tools. Anthropic's post lists several platforms that have integrated skills. I haven't tested portability across them, and I'd expect small differences in how each loader treats optional fields, so I'd test one skill in two tools before promising a client anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a neutral owner changes my planning
&lt;/h2&gt;

&lt;p&gt;Until now, writing a skill felt like writing for one vendor's product that happened to be documented well. If the vendor changed direction, my folder of skills was a bet on their roadmap.&lt;/p&gt;

&lt;p&gt;Moving the standard to a foundation doesn't make that risk vanish, but it changes the odds. The name, the repository and the domain no longer belong to the company with the most to gain from steering them. Anthropic has proposed a starting roadmap of versioned releases, a formal proposal process, and security guidance for people who write and install skills.&lt;/p&gt;

&lt;p&gt;Versioned releases are the one I care about most. When I read the spec page, I didn't see a version number I could pin a skill against. If you have ever debugged a behaviour that changed because a loader quietly started parsing frontmatter differently, you know why a numbered spec is worth more than another feature.&lt;/p&gt;

&lt;p&gt;I'm also holding the promise loosely. Foundation governance tends to be slower than one company shipping on its own schedule, and the post itself talks about process first: a proposal process, versioned releases, a Discord for feedback. I'd plan on stability, not on a burst of features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody should skip: scripts are code you run
&lt;/h2&gt;

&lt;p&gt;The spec allows a &lt;code&gt;scripts/&lt;/code&gt; directory with executable code. That's a feature, and it's also the sentence I'd highlight for any team installing skills from strangers. A skill isn't a prompt. A skill can ship a program, and your agent may be allowed to run it.&lt;/p&gt;

&lt;p&gt;The proposed roadmap mentions security guidance for people who write and install skills, which tells me the maintainers know this. The &lt;code&gt;allowed-tools&lt;/code&gt; field is marked experimental in the spec, so don't treat it as a security boundary yet.&lt;/p&gt;

&lt;p&gt;My rule for now is boring. I read every file in a skill before I install it, scripts first, the same way I'd read an install script piped from curl. For anything that touches client data, I run the agent inside a container with no access to the host Docker socket, for reasons I wrote up in &lt;a href="https://abrarqasim.com/blog/docker-sandbox-ai-agent-why-i-stopped-mounting-docker-sock" rel="noopener noreferrer"&gt;why I stopped mounting docker.sock into agent sandboxes&lt;/a&gt;. The same logic applies here: a skill from a stranger deserves the same suspicion as a package from a stranger.&lt;/p&gt;

&lt;p&gt;The marketplace point is worth saying plainly. When 280 million installs are claimed and anyone can publish, some of that traffic will be people copying a skill they never opened. I would be surprised if nobody has already tried to hide something nasty in a &lt;code&gt;scripts/&lt;/code&gt; folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do this week
&lt;/h2&gt;

&lt;p&gt;Four small things, none of them heroic, and the first one is the cheapest.&lt;/p&gt;

&lt;p&gt;Open the spec and read it. It takes ten minutes, and you'll find out whether your existing skills even validate. Check that each &lt;code&gt;name&lt;/code&gt; matches its folder name and has no uppercase letters or double hyphens. Then rewrite your three worst descriptions so each says what the skill does and when to use it, in those words. Finally, audit anything in a &lt;code&gt;scripts/&lt;/code&gt; folder you didn't write yourself.&lt;/p&gt;

&lt;p&gt;If you maintain skills for a team, add a one-line &lt;code&gt;compatibility&lt;/code&gt; note to any skill that needs git, Docker or network access. The spec says most skills don't need that field, and it's there for exactly the ones that do. That small note saves a colleague from a confusing failure on a machine that lacks a dependency.&lt;/p&gt;

&lt;p&gt;I'll revisit this when the first versioned release lands. If the spec gets a number I can pin, I'll move my own folder over and write down what broke.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/claude-agent-skills-agentic-ai-foundation-donation-what-i-would-check-in-the-spec/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agentskills</category>
      <category>anthropic</category>
      <category>aiagents</category>
      <category>openstandards</category>
    </item>
    <item>
      <title>PHP 8.5.12 RCE Patch: The Checklist I Run on Every Server</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 24 Oct 2026 16:39:00 +0000</pubDate>
      <link>https://dev.to/abyzgenic/php-8512-rce-patch-the-checklist-i-run-on-every-server-4bbc</link>
      <guid>https://dev.to/abyzgenic/php-8512-rce-patch-the-checklist-i-run-on-every-server-4bbc</guid>
      <description>&lt;p&gt;I was halfway through a coffee yesterday evening when the PHP security advisory landed in my feed, and the coffee did not survive the next ten minutes. Two critical remote code execution bugs, unauthenticated, and the PHP team's own wording was "update immediately". No "consider upgrading when convenient". That phrasing is rare enough that I stopped what I was doing.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, restart your workers, and confirm with &lt;code&gt;php -v&lt;/code&gt;. Everything below is the checklist I'm using, the stopgap settings if you can't patch yet, and a few places where I think people will get tripped up.&lt;/p&gt;

&lt;p&gt;I'm not a security researcher. I'm a developer who deploys PHP apps for clients and runs a few boxes of my own, so this is written from the "I have to patch this today and prove it" side of the fence. Read the primary sources too. I link them as I go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the advisory actually says
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.php.net/index.php#2026-10-21-5" rel="noopener noreferrer"&gt;official advisory on php.net&lt;/a&gt; names two bugs. The first is CVE-2026-109618, which the team describes as RCE via out-of-memory corruption. When PHP runs out of memory at a bad moment, it can be interrupted while internal request data is only half initialized, and the cleanup code then trusts that invalid data. The second is CVE-2026-109619, RCE via stale global state. Some request-owned internal state survives after its memory has been freed, and the next request on the same worker can pick it up before normal initialization resets it.&lt;/p&gt;

&lt;p&gt;Both are unauthenticated. The team says the risk is highest for mod_php, where they have a demonstrated exploit that fires before any application code runs. That last part matters for how you think about this. Your framework's middleware, your WAF rules written against your routes, your login page: none of it is in the path. The bug lives below your code.&lt;/p&gt;

&lt;p&gt;The affected list is wide. PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed are all named. If your PHP serves web requests, you're in scope. &lt;a href="https://laravel-news.com/php-critical-rce-security-releases" rel="noopener noreferrer"&gt;Laravel News covered the release&lt;/a&gt; with the platform-by-platform status, which is the quickest way to see where Forge, Vapor, Cloud and Envoyer stand.&lt;/p&gt;

&lt;p&gt;I'm deliberately not going to describe exploitation details. I don't know them beyond what the advisory says, and guessing would be worse than silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which version you're on decides the next hour
&lt;/h2&gt;

&lt;p&gt;Here's where I'd sort yourself, because the work is different for each bucket.&lt;/p&gt;

&lt;p&gt;If you're on 8.2 through 8.5, the fix is a normal package or image update. The &lt;a href="https://www.php.net/ChangeLog-8.php" rel="noopener noreferrer"&gt;8.5.12 changelog&lt;/a&gt; lists the fix for both advisories under Core as "Avoid state corruption via bailout", alongside a long pile of other fixes, including use-after-free bugs in DOM and BZ2 and a file descriptor leak in the built-in server. So even if the RCE bugs somehow didn't apply to you, this is not a release to skip.&lt;/p&gt;

&lt;p&gt;If you're on 8.1 or older, the PHP project no longer ships updates for you. The security team published minimal source backports for 7.4, 8.0 and 8.1 for people building from source, which is generous, but I wouldn't treat it as a plan. If you have a distribution or LTS vendor, ask them for their backport. If you don't, this is the week the "we'll upgrade PHP next quarter" ticket becomes this week's ticket. I wrote about how I plan those upgrade windows in &lt;a href="https://abrarqasim.com/blog/laravel-13-release-date-support-window-i-plan-client-upgrades-around" rel="noopener noreferrer"&gt;my Laravel 13 support window post&lt;/a&gt;, and the short version is that waiting for a security release to force the issue is the expensive way to do it.&lt;/p&gt;

&lt;p&gt;If you use the volunteer-maintained Remi repo, read the notice carefully. Per Laravel News, it won't have updates today, and the PHP team recommends distribution packages or building from source in the meantime. That one catches people because "apt upgrade says nothing to do" feels like good news.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I'm using
&lt;/h2&gt;

&lt;p&gt;Order matters here, so I'm keeping it boring and sequential.&lt;/p&gt;

&lt;p&gt;First, find out what you actually run. Not what you think you run. On each server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
php-fpm8.4 &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null
apachectl &lt;span class="nt"&gt;-M&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; php
ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'php-fpm|php-cgi|frankenphp|lsphp'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI version and the FPM version can differ, and I've been bitten by that before. A box can show a patched &lt;code&gt;php -v&lt;/code&gt; while the FPM pool still runs the old binary because nobody restarted it. Which brings me to the second step: patching the package does not patch the running process. Workers that were started before the update keep running the old code until they restart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--only-upgrade&lt;/span&gt; php8.4-fpm php8.4-cli
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php8.4-fpm
&lt;span class="c"&gt;# containers: pull the new base image, rebuild, redeploy&lt;/span&gt;
docker compose pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, verify against the running thing, not the package manager. For FPM, I check the master process's binary and start time. For containers, I check the image digest of what's actually up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php-fpm8.4 &lt;span class="nt"&gt;-v&lt;/span&gt;
systemctl show php8.4-fpm &lt;span class="nt"&gt;--property&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ActiveEnterTimestamp
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourth, remember the platforms that don't patch you automatically. According to Laravel's status notes, Cloud was patched in place, but Forge servers need you to click Update PHP on each one, and Vapor needs a redeploy (a new deployment, not just a redeploy, if you're on a custom or Docker image runtime). Laravel also says Forge has no patch for PHP 8.0 or 8.1 and that Ubuntu 20.04 or older servers can't get the update at all. For those you need a new server on a supported Ubuntu release. I'd power the old box down until you've moved.&lt;/p&gt;

&lt;p&gt;Fifth, patch Node too if the same box runs it. The Laravel News piece mentions Node.js security releases shipped alongside, and Forge doesn't update Node for you. I haven't read the Node advisory closely enough to say more, so go read it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you can't patch today
&lt;/h2&gt;

&lt;p&gt;The advisory has a section for this, and I'd read it on php.net rather than from my summary, because the details matter. The PHP team is blunt that these measures reduce exposure to specific attack paths and do not make an installation safe. Treat them as a seatbelt on the way to the update, not a substitute.&lt;/p&gt;

&lt;p&gt;For the OOM bug, the advisory suggests setting &lt;code&gt;memory_limit=-1&lt;/code&gt; in a place applications can't override it, and a low &lt;code&gt;post_max_size&lt;/code&gt;. The counterintuitive part is the unlimited memory limit. I'd have bet money the safer move was a tighter limit. The reasoning is that a finite PHP memory limit is a controllable way to trigger the out-of-memory failure, so removing it takes away the lever. They also say an underlying allocation failure can still reach the vulnerable path, so this is partial at best. And they warn against using &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt; as a substitute, because those can themselves cause the vulnerable allocation failure.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in an FPM pool, where &lt;code&gt;php_admin_value&lt;/code&gt; can't be overridden by app code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; /etc/php/8.4/fpm/pool.d/www.conf
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[memory_limit]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;-1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[post_max_size]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;8M&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[upload_max_filesize]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;6M&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[session.upload_progress.enabled]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 8M figure is the advisory's suggestion for apps without file uploads, and 32M for apps that need uploads, with &lt;code&gt;upload_max_filesize&lt;/code&gt; kept below &lt;code&gt;post_max_size&lt;/code&gt;. Adjust to your app.&lt;/p&gt;

&lt;p&gt;For the stale global state bug, the advisory says to set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; and restart every worker, using php.ini, an FPM pool or server config that applies before request parsing. It specifically says not to rely on &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt;, because they apply too late. There's also a PHP 8.5 item about &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt;. Read the full text, since I'm only summarizing part of it.&lt;/p&gt;

&lt;p&gt;One thing I'd add from my own habits, and this is opinion rather than advisory: if unlimited memory makes you nervous on a small VPS, pair it with a cgroup or container memory limit that kills only the worker. The advisory itself recommends a container or cgroup limit as an availability boundary. On my own boxes that's a systemd drop-in with &lt;code&gt;MemoryMax&lt;/code&gt; on the FPM unit, which I cover in my &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first" rel="noopener noreferrer"&gt;VPS hardening baseline&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still unsure about
&lt;/h2&gt;

&lt;p&gt;I don't know yet how many installs are being probed. I haven't seen exploit traffic on my own servers, and absence of evidence on a handful of boxes means nothing. I also can't tell you whether the interim settings hold up, because the advisory itself hedges on them.&lt;/p&gt;

&lt;p&gt;The other open question is logs. If an attacker got in before you patched, a clean &lt;code&gt;php -v&lt;/code&gt; tells you nothing about the past. I'm checking web server access logs for oddly large or oddly shaped requests since the 21st and looking for unexpected files and processes under the web user. If you run anything with client data on it, assume you may want a proper incident review rather than my grep one-liners. For client work I'd rather write that email now than later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do these four things before you close the laptop
&lt;/h2&gt;

&lt;p&gt;Check the PHP version on every server and container you own, then restart FPM or Apache and recheck against the running process. Move anything on PHP 8.1 or older onto a supported release, or get a written backport commitment from your vendor. If a box can't be patched tonight, apply the advisory's interim settings and put a calendar block on the patch. And if you maintain client sites, send each client a two-line note saying what you patched and when, because they will hear about this from somewhere else and it's better coming from you. If you want help with a patch sweep or the upgrade plan behind it, my work and contact details are at &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/php-8-5-12-security-update-rce-patch-checklist-php-fpm-mod-php/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>phpfpm</category>
      <category>laravel</category>
    </item>
  </channel>
</rss>
