<?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: Iain</title>
    <description>The latest articles on DEV Community by Iain (@notaflightrisk).</description>
    <link>https://dev.to/notaflightrisk</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%2F4202550%2F66a911a8-f66b-4465-baa6-6468a8cd8c24.jpg</url>
      <title>DEV Community: Iain</title>
      <link>https://dev.to/notaflightrisk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/notaflightrisk"/>
    <language>en</language>
    <item>
      <title>Your reverse proxy makes the internet look like localhost</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 13 Nov 2026 21:33:20 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/your-reverse-proxy-makes-the-internet-look-like-localhost-197m</link>
      <guid>https://dev.to/notaflightrisk/your-reverse-proxy-makes-the-internet-look-like-localhost-197m</guid>
      <description>&lt;p&gt;You've got Transmission or Ollama on your home server, bound to &lt;code&gt;127.0.0.1&lt;/code&gt; so nothing else on the network can touch it. Now you want it from your phone, so you put Cloudflare Tunnel or an nginx reverse proxy in front. Feels like another layer between the internet and the app.&lt;/p&gt;

&lt;p&gt;It's the opposite. The proxy runs on the same box, and when it forwards a request it opens a fresh connection to &lt;code&gt;127.0.0.1&lt;/code&gt;. The app never sees your phone, or the internet. It sees a connection from loopback, the one address it was told only this machine could use. "Only this machine can reach it" quietly became "anyone who can reach the proxy can reach it", and the proxy is on the internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.peng.ly/tunnel-trusts-localhost/" rel="noopener noreferrer"&gt;Try it on the blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I put three of them behind a Caddy proxy on the same box, all on their shipped defaults, and sent requests from outside with no password. Versions are from Alpine's packages on 13 November: Transmission 4.1.4, Syncthing 2.1.7, Ollama 0.17.7.&lt;/p&gt;

&lt;h2&gt;
  
  
  They see it coming, then tell you to turn it off
&lt;/h2&gt;

&lt;p&gt;Here's the bit that makes this worth a post. These apps aren't naive about loopback trust. All three ship a defence against exactly this, built for a related attack called DNS rebinding, where a web page you're visiting quietly points a name at &lt;code&gt;127.0.0.1&lt;/code&gt; and talks to your local services through your browser. The defence is to check the &lt;code&gt;Host&lt;/code&gt; header and refuse anything that isn't &lt;code&gt;localhost&lt;/code&gt; or on a whitelist.&lt;/p&gt;

&lt;p&gt;So out of the box, through a proxy that forwards your real hostname, all three say no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transmission  421 Misdirected Request
Syncthing     403 Host check error
Ollama        403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transmission's 421 even explains itself, and points you at the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;421: Misdirected Request. Transmission received your request, but the
hostname was unrecognized. To fix this, choose one of the following options:
 - Enable password authentication, then any hostname is allowed.
 - Add the hostname you want to use to the whitelist in settings.
   [...]
This requirement has been added to help prevent DNS Rebinding attacks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fair enough. So you go and read the reverse-proxy guide. Ollama's FAQ tells you to set &lt;code&gt;Host&lt;/code&gt; to &lt;code&gt;localhost:11434&lt;/code&gt; for nginx, ngrok and Cloudflare. For Syncthing it's the top answer in a &lt;a href="https://github.com/syncthing/docs/issues/380" rel="noopener noreferrer"&gt;years-old docs issue&lt;/a&gt;. Either way it's the natural fix, because otherwise the app blocks your shiny new proxy. One line in the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="nc"&gt;Host&lt;/span&gt; &lt;span class="n"&gt;localhost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's the Host-check gone. Every request now arrives claiming to be &lt;code&gt;localhost&lt;/code&gt;, from &lt;code&gt;127.0.0.1&lt;/code&gt;, with no password. Here's what each one hands over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transmission: the whole RPC, read and write
&lt;/h2&gt;

&lt;p&gt;Transmission's RPC is the interface Tavis Ormandy &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2018-5702" rel="noopener noreferrer"&gt;turned into remote code execution in 2018&lt;/a&gt; through DNS rebinding. Out of the box it wants no password (&lt;code&gt;rpc-authentication-required&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;), just the Host-check you've now removed.&lt;/p&gt;

&lt;p&gt;The first request back a 409 with a session id. That looks like a login, but it isn't - it's an anti-CSRF nonce, handed to anyone who asks. Send it straight back and the RPC answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ curl proxy/transmission/rpc -d &lt;span class="s1"&gt;'{"method":"session-get"}'&lt;/span&gt;
409 Conflict   X-Transmission-Session-Id: gn2cIH...

$ curl proxy/transmission/rpc -H &lt;span class="s1"&gt;'X-Transmission-Session-Id: gn2cIH...'&lt;/span&gt; \
    -d &lt;span class="s1"&gt;'{"method":"session-get"}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"result"&lt;/span&gt;:&lt;span class="s2"&gt;"success"&lt;/span&gt;,&lt;span class="s2"&gt;"arguments"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"version"&lt;/span&gt;:&lt;span class="s2"&gt;"4.1.4"&lt;/span&gt;,
 &lt;span class="s2"&gt;"download-dir"&lt;/span&gt;:&lt;span class="s2"&gt;"/root/Downloads"&lt;/span&gt;, ...&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's read. &lt;code&gt;session-set&lt;/code&gt; works the same way, so an outside request can change where Transmission writes files too - the first half of Ormandy's trick. No credentials anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Syncthing: the admin API, and its master key
&lt;/h2&gt;

&lt;p&gt;Syncthing ships with no GUI password. Its REST API wants a CSRF token for writes, but it hands one out to anyone who loads the page, since the page has no password either. Grab the token, then read the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl proxy/rest/system/config
{"gui":{"user":"","password":"","apiKey":"..."}, "devices":[...]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No user, no password, and &lt;code&gt;apiKey&lt;/code&gt; right there in the response. That key is Syncthing's all-access credential - with it you can rewrite the whole config, add your own device, redirect folders. Even if you later set a GUI password, the key you leaked still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ollama: your models, and the GPU behind them
&lt;/h2&gt;

&lt;p&gt;Ollama's the plainest. With the &lt;code&gt;Host&lt;/code&gt; rewrite its API just answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ curl proxy/api/tags
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"models"&lt;/span&gt;:&lt;span class="o"&gt;[&lt;/span&gt; ... &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/api/tags&lt;/code&gt; lists your models. There's no auth in front of any of it, so &lt;code&gt;/api/pull&lt;/code&gt;, &lt;code&gt;/api/delete&lt;/code&gt; and &lt;code&gt;/api/generate&lt;/code&gt; answer the same way - an outsider can pull a model onto your disk, delete the ones you have, or run prompts on your GPU. Ollama's had no auth of its own for most of its life, on the assumption it's only ever reachable from the machine it runs on. Which was true until you tunnelled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not everything falls over
&lt;/h2&gt;

&lt;p&gt;qBittorrent 5.3.0 is the counter-example. Through the same proxy it returns &lt;code&gt;403 Forbidden&lt;/code&gt;, because it now prints a random one-time WebUI password on first start and its old "bypass auth for localhost" toggle ships off. So this isn't a law of reverse proxies - it's three apps whose "localhost is safe" assumption the proxy quietly breaks, and one that stopped making it.&lt;/p&gt;

&lt;p&gt;The difference is just whether the app has real auth that doesn't lean on where the request came from. A password, an API key you actually have to send, a token at the edge - any of those and the proxy's loopback address buys an attacker nothing.&lt;/p&gt;

&lt;p&gt;And it's not a lab toy. GPUStack, a server for running models across GPUs, had the same bug: any request from loopback was handed admin, and a same-box reverse proxy made every outside request look like loopback. It was &lt;a href="https://github.com/gpustack/gpustack/security/advisories/GHSA-gj9m-f8ph-pmhr" rel="noopener noreferrer"&gt;exploited in the wild and added to CISA's known-exploited list last week&lt;/a&gt;. Same shape, a bigger blast radius.&lt;/p&gt;

&lt;p&gt;So before you tunnel anything, the one rule: a tunnel is not a password.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Turn on the app's own auth, or put a password or SSO layer at the proxy, before it's reachable.&lt;/li&gt;
&lt;li&gt;Never "fix" a &lt;code&gt;Host&lt;/code&gt;-check error by whitelisting your public hostname or rewriting &lt;code&gt;Host&lt;/code&gt; to &lt;code&gt;localhost&lt;/code&gt; without also setting auth. That error is the rebinding defence doing its job.&lt;/li&gt;
&lt;li&gt;Check any "trusted networks", "bypass for local addresses" or "no auth on localhost" toggle. Behind a same-box proxy, local means everyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What gets me is the shape of it. These apps saw loopback abuse coming and shipped a defence against it, and then the standard way to get them working behind a proxy is the one thing that switches it back off.&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Chrome's dark mode stops flipping logos at 65 pixels</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Thu, 12 Nov 2026 16:47:24 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/chromes-dark-mode-stops-flipping-logos-at-65-pixels-296g</link>
      <guid>https://dev.to/notaflightrisk/chromes-dark-mode-stops-flipping-logos-at-65-pixels-296g</guid>
      <description>&lt;p&gt;Here's Chrome's forced dark mode drawing six favicons, first at 64 pixels and then at 65.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhh0zlnc2iacfil791mx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhh0zlnc2iacfil791mx.png" alt="Six favicons on a dark page, each shown three times. Normal, on white: Uber, Medium and X are white on black tiles, Apple, OpenAI and Wikipedia are black on white. Dark at 64px: Uber and Medium have turned into black on white tiles, Apple is now white on black, X, OpenAI and Wikipedia are unchanged. Dark at 65px: all six are unchanged." width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Chromium 156 with forced dark mode on. The "Normal" row is how they look on a white page, kept out&lt;br&gt;
  of dark mode, so the see-through corners show white there and dark below.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 64 it turned Uber's and Medium's black tiles white, gave Apple a dark version, and left OpenAI and Wikipedia as bright white squares on a dark page. At 65 it didn't touch any of them.&lt;/p&gt;

&lt;p&gt;Forced dark is the "Auto Dark Mode for Web Contents" flag on desktop, and the "Apply dark theme to sites, when possible" box in Chrome's theme settings on Android. It repaints light pages dark unless the site says it has a dark mode of its own. Hardly anyone has it on, about one Chrome page load in 1,850 by &lt;a href="https://chromestatus.com/metrics/feature/timeline/popularity/3579" rel="noopener noreferrer"&gt;Chrome's own count&lt;/a&gt;. But Safari announced &lt;a href="https://webkit.org/blog/18541/" rel="noopener noreferrer"&gt;its own version&lt;/a&gt; last week, and I wanted to know how a browser decides which of your pictures to flip.&lt;/p&gt;
&lt;h2&gt;
  
  
  It gets a ruler out first
&lt;/h2&gt;

&lt;p&gt;Chrome only considers flipping a picture if it's drawn at &lt;a href="https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/paint/paint_auto_dark_mode.cc" rel="noopener noreferrer"&gt;64 CSS pixels or less&lt;/a&gt; each way. Anything bigger counts as a photo and is left alone, whatever's in it. Screen resolution and page zoom are taken back out first, so 64 is 64 on a phone or a 4K monitor. It does go by the size in the layout rather than what you see, though, so a 40px icon blown up with &lt;code&gt;transform: scale(2)&lt;/code&gt; is 80 pixels on screen and still flips. (The one side door is for images 8 pixels thin or less, which Chrome treats as separator lines and considers at any size.)&lt;/p&gt;

&lt;p&gt;The 64 is new. Until Chrome 151 in July, a picture counted as an icon if it took up no more than 13% of the screen each way, so on a 1080p monitor anything up to about 250 by 140 pixels was fair game, and less on a smaller screen. The &lt;a href="https://github.com/chromium/chromium/commit/2c0b79c57c1726f359bcd87137474e854b1aeb38" rel="noopener noreferrer"&gt;change that replaced it&lt;/a&gt; is pretty frank about why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The previous ratio-based threshold scaled with the screen size and device scale factor, so the&lt;br&gt;
same image could be classified differently depending on the display it was shown on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/chromium/chromium/commit/2c0b79c57c1726f359bcd87137474e854b1aeb38" rel="noopener noreferrer"&gt;Chromium commit 2c0b79c&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Then it counts the greys
&lt;/h2&gt;

&lt;p&gt;A picture that gets past the ruler goes to &lt;a href="https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/platform/graphics/dark_mode_image_classifier.cc" rel="noopener noreferrer"&gt;a classifier&lt;/a&gt; that samples up to 1,000 of its pixels. Pictures that are mostly strong colour are left alone, which is why Google, Spotify, Slack and ten other logos I tried that are mostly colour didn't flip. Grey ones get sorted into 16 shades of grey, and Chrome counts how many shades turn up. Fewer than 13 and it flips the picture, unless it's light marks on a see-through background. 13 or more and it can't make its mind up, so it asks a neural net.&lt;/p&gt;

&lt;p&gt;I copied the classifier into Python, the net's weights and all, and it agrees with Chrome on all 35 pictures I'd tried. Here's how the grey favicons went, with the shades as my copy counted them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Favicon&lt;/th&gt;
&lt;th&gt;Shades of grey&lt;/th&gt;
&lt;th&gt;Decided by&lt;/th&gt;
&lt;th&gt;Flipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Count&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Count&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Count&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uber&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Count&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Net&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Net&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wikipedia&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Net&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Net&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Uber's and X's are the same idea, a white mark on a black tile. The smoothing round Uber's letters comes to 12 shades, and X's diagonals make 13. One more shade, and it's the net's problem.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/platform/graphics/darkmode/darkmode_classifier.cc" rel="noopener noreferrer"&gt;net itself&lt;/a&gt; is four inputs, ten neurons and one output, 61 numbers in all, compiled straight into Chrome as a block of hex. Its inputs are whether the picture's in colour, how many shades it found, how much of it is see-through, and how much is background. When I ran it over a spread of made-up inputs, that last one decided most of the answers. Chrome works that out by going through the picture from the top left and counting see-through patches until it hits a solid one, so a solid square favicon has no background at all, and the net said no to every one of them. OpenAI's logo is all curves, which means lots of greys, which means it stays a white square.&lt;/p&gt;

&lt;p&gt;So Chrome turned two black tiles white and left two white squares white. It did get Apple's right.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping out of it
&lt;/h2&gt;

&lt;p&gt;If your site has a dark mode of its own, say so, and Chrome leaves the whole page alone:&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="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;meta name="color-scheme" content="light dark"&amp;gt;&lt;/code&gt; tag does the same. That only helps if you've actually written dark styles, mind, since the browser hands you a dark background and expects you to do the rest. It also only worked when Chrome's own theme was dark. With a light theme and the flag on, it flipped the logo anyway. For one picture that should never change, &lt;code&gt;color-scheme: only light&lt;/code&gt; on the &lt;code&gt;img&lt;/code&gt; keeps it as it is. Plain &lt;code&gt;color-scheme: light&lt;/code&gt;, without the &lt;code&gt;only&lt;/code&gt;, didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your header logo can take the big one with it
&lt;/h2&gt;

&lt;p&gt;SVG files in an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; get the same ruler, so a big SVG logo on its own is left alone. But if the same file has already been drawn small further up the page, say as the logo in your header, the big copy gets whatever the small one got. Here's a black circle on a white square, drawn at 32 pixels and then at 120, once as a PNG and once as an SVG:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55tr5movr1m53de2wygd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55tr5movr1m53de2wygd.png" alt="Two columns on a dark page, PNG and SVG, each a small square above a big one. PNG: the small one has flipped to a white circle on black, the big one is a black circle on a white square as normal. SVG: both the small and the big one are plain white squares, with no circle." width="520" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same logo as a PNG and as an SVG file, each drawn at 32px and then 120px, in Chromium 156 with&lt;br&gt;
  forced dark mode on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The PNG copies are judged one at a time, so only the small one flips. The SVG's circle and its square both turn white at 32 pixels, and the 120 pixel one goes the same way, so the logo's just gone, twice. Swap the order round so the big one's drawn first and neither changes. &lt;code&gt;color-scheme: only light&lt;/code&gt; on the big copy saves it, too.&lt;/p&gt;

&lt;p&gt;Which is a bit awkward, as WebKit's advice for getting your site ready for Safari's version is to swap your bitmap logos for SVG.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>chrome</category>
      <category>browsers</category>
    </item>
    <item>
      <title>Linux thinks Ireland is on daylight saving all winter</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Thu, 12 Nov 2026 16:46:54 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/linux-thinks-ireland-is-on-daylight-saving-all-winter-4ni2</link>
      <guid>https://dev.to/notaflightrisk/linux-thinks-ireland-is-on-daylight-saving-all-winter-4ni2</guid>
      <description>&lt;p&gt;Ireland's clocks do exactly what the UK's do: GMT in winter, an hour ahead in summer. Ask most computers, though, and Ireland's on daylight saving time right now, in November.&lt;/p&gt;

&lt;p&gt;Which is why Robot Framework, the test automation tool, can't tell the time in UTC from Dublin. I asked it at 16:07 UTC this afternoon, with the clock set to Dublin and then to London:&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="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Europe/Dublin robot clocks.robot
NOW 2026-11-12 16:07:44
UTC 2026-11-12 15:07:44
$ &lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Europe/London robot clocks.robot
NOW 2026-11-12 16:07:45
UTC 2026-11-12 16:07:45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;London's right. Dublin's an hour early, and from the end of March it'll be an hour late instead. It's been like that since 2018.&lt;/p&gt;

&lt;h2&gt;
  
  
  Irish law makes winter the exception
&lt;/h2&gt;

&lt;p&gt;In Ireland, summer time is the standard. The &lt;a href="https://www.irishstatutebook.ie/eli/1968/act/23/enacted/en/print.html" rel="noopener noreferrer"&gt;Standard Time Act 1968&lt;/a&gt; put the clocks an hour ahead of GMT "throughout the year", and the &lt;a href="https://www.irishstatutebook.ie/eli/1971/act/17/enacted/en/print.html" rel="noopener noreferrer"&gt;1971 amendment&lt;/a&gt; added a "period of winter time" when they're on GMT. So legally, the Irish clocks go back for winter rather than forward for summer.&lt;/p&gt;

&lt;p&gt;The tz database, which nearly every computer gets its time zones from, took that literally in 2018 after Derick Rethans (who looks after PHP's dates) &lt;a href="https://github.com/eggert/tz/blob/main/NEWS" rel="noopener noreferrer"&gt;pointed it out&lt;/a&gt;. Dublin's winter is now daylight saving time of minus one hour. Some software couldn't cope with a negative one, so there's also a "rearguard" copy of the data, done the old way round.&lt;/p&gt;

&lt;p&gt;The offsets and the names, GMT and IST, are the same either way. Only one flag changes, &lt;code&gt;tm_isdst&lt;/code&gt;. Here's &lt;code&gt;zdump&lt;/code&gt; on two Linux distros at the moment the clocks went back last month (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debian:trixie  Oct 25 01:00 GMT isdst=1
rockylinux:9   Oct 25 01:00 GMT isdst=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rocky, like Red Hat, ships the rearguard copy. Debian, Ubuntu, Fedora, Arch and openSUSE all ship the main one. I asked Python, Go, Ruby, Perl, PHP, .NET, Rust and Postgres whether a winter's day in Dublin is daylight saving, and they all said yes. Java said no, as it turns the negative one back into an ordinary summer time when it builds its own copy of the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python's snippet mixes two answers
&lt;/h2&gt;

&lt;p&gt;The top answer to "&lt;a href="https://stackoverflow.com/questions/3168096/getting-computers-utc-offset-in-python" rel="noopener noreferrer"&gt;Getting computer's UTC offset in Python&lt;/a&gt;" on Stack Overflow, from 2010 and with 111 votes, is this:&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="n"&gt;is_dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daylight&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localtime&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;span class="n"&gt;tm_isdst&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;utc_offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;altzone&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_dst&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time.timezone&lt;/code&gt; is meant to be your standard offset and &lt;code&gt;time.altzone&lt;/code&gt; the daylight saving one, both in seconds west of UTC (hence the minus). On Linux, Python doesn't get those from the C library. It looks at the clocks in January and July and calls whichever is further ahead daylight saving, which is right nearly everywhere and backwards in Ireland.&lt;/p&gt;

&lt;p&gt;So in Dublin, Python's &lt;code&gt;timezone&lt;/code&gt; is 0 (GMT) and its &lt;code&gt;altzone&lt;/code&gt; is -3600 (IST). But &lt;code&gt;tm_isdst&lt;/code&gt; comes straight from the C library, which calls winter daylight saving. Mix the two and you get it backwards every day of the year:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In Dublin&lt;/th&gt;
&lt;th&gt;Real offset&lt;/th&gt;
&lt;th&gt;&lt;code&gt;tm_isdst&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;The snippet says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;January&lt;/td&gt;
&lt;td&gt;+0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;+0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;time.daylight&lt;/code&gt; is 1 in Dublin, so the guard doesn't help. The same trick for the zone's name, &lt;code&gt;time.tzname[tm_isdst]&lt;/code&gt;, says IST all winter and GMT all summer.&lt;/p&gt;

&lt;p&gt;Python's &lt;a href="https://docs.python.org/3/library/time.html#timezone-constants" rel="noopener noreferrer"&gt;docs&lt;/a&gt; do warn that those constants "may be incorrect for times in the past". In Dublin, the present too. They say to use &lt;code&gt;tm_gmtoff&lt;/code&gt; and &lt;code&gt;tm_zone&lt;/code&gt; instead, which come straight from the C library:&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="n"&gt;lt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tm_gmtoff&lt;/span&gt;  &lt;span class="c1"&gt;# 0, in seconds&lt;/span&gt;
&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tm_zone&lt;/span&gt;    &lt;span class="c1"&gt;# 'GMT'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or this, if you'd rather have a &lt;code&gt;timedelta&lt;/code&gt;:&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="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;span class="nf"&gt;astimezone&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;span class="nf"&gt;utcoffset&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;A &lt;a href="https://github.com/python/cpython/pull/161338" rel="noopener noreferrer"&gt;CPython PR&lt;/a&gt; from Neil Schemenauer would make Python on Linux read the C library's values, and its tests expect that to put Dublin right. It's still waiting for a review.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's in llm, and a few hundred other places
&lt;/h2&gt;

&lt;p&gt;GitHub's code search finds &lt;code&gt;time.altzone if time.localtime().tm_isdst&lt;/code&gt; in about 270 Python files, and there are plenty more written other ways. Robot Framework's is the one above.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/simonw/llm" rel="noopener noreferrer"&gt;llm&lt;/a&gt;, Simon Willison's command line tool for language models, has a built-in &lt;a href="https://github.com/simonw/llm/blob/c12fd50b9f/llm/tools.py" rel="noopener noreferrer"&gt;&lt;code&gt;llm_time&lt;/code&gt; tool&lt;/a&gt; that a model can call to find out the time. It uses both tricks, so in Dublin today it tells the model this (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"utc_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-12 16:07:51 UTC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"local_timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"IST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"local_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-12 16:07:51"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"timezone_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"UTC+01:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"is_dst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local time and UTC match, then it says they're an hour apart, in the summer zone. Good luck to the model.&lt;/p&gt;

&lt;p&gt;It's also in AWS's &lt;a href="https://github.com/aws-cloudformation/aws-cloudformation-templates/blob/d92ae9b9a5/CloudFormation/MacrosExamples/DateFunctions/handler.py" rel="noopener noreferrer"&gt;date functions macro&lt;/a&gt; for CloudFormation, which gets away with it on Lambda, where the clock's UTC unless you change it. And in Stockfish's &lt;a href="https://github.com/official-stockfish/fishtest/blob/036a55fc7b/worker/worker.py" rel="noopener noreferrer"&gt;fishtest worker&lt;/a&gt;, which logs a local offset of +01:00 all through an Irish winter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Morocco's an hour out until January
&lt;/h2&gt;

&lt;p&gt;Ireland's at least wrong the same way every year. Morocco moved to GMT for good on 20 September (it's in tz release 2026c). Python works its constants out from this January, when Morocco was on +1, so the same snippet has said +1 there ever since, against a real +0.&lt;/p&gt;

&lt;p&gt;A Python started in 2027 looks at the new January and gets it right. Anything started before then stays an hour out until it's restarted.&lt;/p&gt;

</description>
      <category>python</category>
      <category>linux</category>
      <category>timezones</category>
    </item>
    <item>
      <title>Self-hosted Mattermost sends your DMs via Google and Apple</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Wed, 11 Nov 2026 18:59:05 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/self-hosted-mattermost-sends-your-dms-via-google-and-apple-3jal</link>
      <guid>https://dev.to/notaflightrisk/self-hosted-mattermost-sends-your-dms-via-google-and-apple-3jal</guid>
      <description>&lt;p&gt;Mattermost's &lt;a href="https://docs.mattermost.com/deployment-guide/mobile/mobile-faq.html" rel="noopener noreferrer"&gt;mobile FAQ&lt;/a&gt; has a line for anyone who self-hosts it to keep their chat to themselves:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Default server settings have message contents turned off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what a brand-new Mattermost 12.1 server sent when alice DMed bob, who has the Android app. (it's trimmed, but nothing's changed)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"the spare key is under the blue plant pot, code 4417"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"sender_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"@alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"channel_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"@alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"android_rn-v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"device_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"FAKE-FCM-TOKEN-0001"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That goes to &lt;code&gt;push-test.mattermost.com&lt;/code&gt;, Mattermost's free Test Push Notification Service (TPNS), which passes it to Google's Firebase for Android phones or Apple's push service for iPhones, and they then put it on bob's lock screen. An @mention in a channel works the same way, as "&lt;a class="mentioned-user" href="https://dev.to/alice"&gt;@alice&lt;/a&gt;: &lt;a class="mentioned-user" href="https://dev.to/bob"&gt;@bob&lt;/a&gt; the wifi password is now otter-pancake-91".&lt;/p&gt;

&lt;p&gt;I ran the server in Docker on a network with no way out. The only setting I touched was the relay's address, so the push came to a little listener of mine instead, and everything else was how the server came. The hop from the relay to Google or Apple is from the &lt;a href="https://github.com/mattermost/mattermost-push-proxy" rel="noopener noreferrer"&gt;relay's source&lt;/a&gt;, which copies &lt;code&gt;message&lt;/code&gt; straight into what it sends them.&lt;/p&gt;

&lt;p&gt;By default it does this for every DM and @mention, unless you're actually looking at that conversation at the time or you're on Do Not Disturb. If your server was set up since mid-2020 and nobody's changed Push Notification Contents, yours does it too. You can see it in the System Console, under Site Configuration, Notifications, or on the server if local mode's on (it is in the Docker image):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mmctl --local config get EmailSettings.PushNotificationContents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"full"&lt;/code&gt; means the message goes in the push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generic, according to the docs
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.mattermost.com/administration-guide/configure/site-configuration-settings.html" rel="noopener noreferrer"&gt;settings docs&lt;/a&gt; mark "Generic description with sender and channel names" as the default, which only says who sent it and where. A few lines further down that FAQ, it also says push notifications are disabled by default. My new server had them on, pointed at the test relay.&lt;/p&gt;

&lt;p&gt;The server's own default is &lt;code&gt;full&lt;/code&gt;, and has been since a &lt;a href="https://github.com/mattermost/mattermost/pull/14565" rel="noopener noreferrer"&gt;pull request in May 2020&lt;/a&gt; that made it full "to improve user experience in Mattermost for new installs". It's still there in 12.1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PushNotificationContents&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PushNotificationContents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FullNotification&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;That only fills in a setting the config file hasn't got. A server from before 5.24 already had &lt;code&gt;generic&lt;/code&gt; written into its config, so it kept it, and anything newer got &lt;code&gt;full&lt;/code&gt;. The Helm chart and Mattermost's own Docker setup don't change it, and the docs still say generic today.&lt;/p&gt;

&lt;p&gt;To be fair, the System Console tells you if you go and look. Its help text says full content "is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM)". But you'd only go and look if you didn't believe the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three companies in the middle
&lt;/h2&gt;

&lt;p&gt;The connection to the relay is HTTPS, but the relay itself gets the message as plain JSON, and Mattermost's &lt;a href="https://mattermost.com/hpns-terms/" rel="noopener noreferrer"&gt;terms&lt;/a&gt; say "messages sent via the TPNS will not be encrypted" and "You assume all risk". The relay's open source code doesn't log the message, but I can't see what push-test.mattermost.com actually runs. Mattermost's own docs say TPNS isn't recommended for production, which is a funny thing for every new server to point at.&lt;/p&gt;

&lt;p&gt;Then Google or Apple, who get the text, who sent it and the channel name. In December 2023 &lt;a href="https://www.reuters.com/technology/cybersecurity/governments-spying-apple-google-users-through-push-notifications-us-senator-2023-12-06/" rel="noopener noreferrer"&gt;Senator Wyden said&lt;/a&gt; foreign governments had been asking both of them for push notification data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other messaging apps don't do this
&lt;/h2&gt;

&lt;p&gt;Push always goes via Google or Apple, but what's in it is up to the server and the app. Here's what each one sends by default:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;What's in the push&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mattermost 12&lt;/td&gt;
&lt;td&gt;The message, who sent it and the channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rocket.Chat&lt;/td&gt;
&lt;td&gt;The message, who sent it and the room&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matrix, with Element X&lt;/td&gt;
&lt;td&gt;An event ID, and the app fetches the message itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prosody (XMPP)&lt;/td&gt;
&lt;td&gt;No message or sender, at most a placeholder "New Message!"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zulip 12&lt;/td&gt;
&lt;td&gt;Only ciphertext the app can read, if the app's up to date&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the Mattermost row is from my capture. The rest are from each project's source and docs, and Rocket.Chat's has been known for years. Its staff &lt;a href="https://forums.rocket.chat/t/push-notifications-and-gdpr/3802" rel="noopener noreferrer"&gt;said on its forum&lt;/a&gt; in 2021 that the message would "STILL be in plain text" even through a European gateway.&lt;/p&gt;

&lt;p&gt;Mattermost and Rocket.Chat can both do it the Element X way, where the push only carries an ID and the app fetches the rest, and both want a licence for it. Mattermost calls it "Full message content fetched from the server on receipt". I picked it on the free Team Edition anyway, and the server quietly sent the generic version instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching it off
&lt;/h2&gt;

&lt;p&gt;Set Push Notification Contents to "Generic description with sender and channel names", or "Generic description with only sender name". The setting lives under &lt;code&gt;EmailSettings&lt;/code&gt;, for some reason, so with mmctl or Docker it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mmctl --local config &lt;span class="nb"&gt;set&lt;/span&gt; EmailSettings.PushNotificationContents generic
&lt;span class="nv"&gt;MM_EMAILSETTINGS_PUSHNOTIFICATIONCONTENTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;generic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bob's phone just says alice sent him a message, and he has to open the app to find out where the key is. Running your own relay is the other way, but only Mattermost's relay holds the Apple and Google keys for the store apps, so that means building your own apps too.&lt;/p&gt;

&lt;p&gt;Mattermost's &lt;a href="https://docs.mattermost.com/administration-guide/upgrade/admin-onboarding-tasks.html" rel="noopener noreferrer"&gt;admin onboarding checklist&lt;/a&gt; has a step for this setting as well. Number 4 is "Enable full content push notifications".&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>security</category>
      <category>privacy</category>
      <category>mattermost</category>
    </item>
    <item>
      <title>jsDelivr handed out the hijacked axios 940,000 times</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Mon, 09 Nov 2026 16:49:50 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/jsdelivr-handed-out-the-hijacked-axios-940000-times-208f</link>
      <guid>https://dev.to/notaflightrisk/jsdelivr-handed-out-the-hijacked-axios-940000-times-208f</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cdn.jsdelivr.net/npm/axios@1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that in a script tag and the &lt;code&gt;@1&lt;/code&gt; means "whatever the newest 1.x is right now". For about three hours on 31 March, the newest 1.x was 1.14.1, the version an attacker published from the lead maintainer's npm account. jsDelivr's public stats count 941,960 requests for it, and 98.5% of them were for &lt;code&gt;dist/axios.min.js&lt;/code&gt;, the browser build.&lt;/p&gt;

&lt;p&gt;None of that did any harm. The malware was in a new dependency, &lt;code&gt;plain-crypto-js&lt;/code&gt;, which dropped a remote access trojan when you ran &lt;code&gt;npm install&lt;/code&gt; (&lt;a href="https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform" rel="noopener noreferrer"&gt;Snyk has the details&lt;/a&gt;). A script tag doesn't install anything, so the browser build was clean, and the advice at the time said so:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you are loading axios via a CDN such as unpkg, none of this executes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.iblue.team/incident-response-1/axios-npm-supply-chain-attack.md" rel="noopener noreferrer"&gt;iblue.team&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is true, but only because the attacker was after developers' laptops. Their release still went out to all those script tags, it just had nothing in it for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ones that did go after browsers
&lt;/h2&gt;

&lt;p&gt;Two earlier hijacks did aim their malware at browsers, and reached a tiny fraction of that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hijack&lt;/th&gt;
&lt;th&gt;Aimed at browsers?&lt;/th&gt;
&lt;th&gt;Requests for the bad versions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lottie-player, Oct 2024&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;3,965&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chalk, debug and co, Sep 2025&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;884&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;axios, Mar 2026&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;941,960&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised" rel="noopener noreferrer"&gt;chalk, debug and the rest&lt;/a&gt; carried exactly the right sort of malware for a web page, code that swapped crypto wallet addresses, but almost nobody loads chalk from a CDN. &lt;a href="https://github.com/LottieFiles/lottie-player/issues/254" rel="noopener noreferrer"&gt;lottie-player&lt;/a&gt; is an animation player that people really do drop into pages with a script tag, and about 125,000 requests a day followed its newest version. Its three bad versions asked visitors to connect their crypto wallets, were replaced the same day, and still got nearly 4,000 requests. &lt;a href="https://decrypt.co/289284/animation-tool-lottie-player-hit-by-supply-chain-attack-causes-723k-bitcoin-theft" rel="noopener noreferrer"&gt;At least one person lost 10 bitcoin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Put lottie's payload on axios's crowd for three hours and it's a very different write-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it got there so fast
&lt;/h2&gt;

&lt;p&gt;The release before the hijack shows how quickly that &lt;code&gt;@1&lt;/code&gt; moves. axios 1.14.0 came out at 19:01 UTC on 27 March. Through that week, 1.13.6 had been getting 17 to 20 million jsDelivr requests a day. The day after, it got 750,000.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzs9qfwbv8d9apjcs2yqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzs9qfwbv8d9apjcs2yqa.png" alt="Daily jsDelivr requests for two axios versions, in millions" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily jsDelivr requests for two axios versions, in millions&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/cdn-floating-tags/axios.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1.14.0 came out on the evening of 27 March. The hijacked 1.14.1 was out for three hours on 31&lt;br&gt;
  March, too few requests to see at this scale.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So about 17 million of axios's 25 million daily jsDelivr requests take whatever's newest, and they all move over within a day. Three hours of that would be about two million, and 1.14.1 got under half. I'm not sure why. My guess is jsDelivr's edge servers, which hang on to what &lt;code&gt;@1&lt;/code&gt; means for up to 12 hours (&lt;code&gt;s-maxage=43200&lt;/code&gt;), so plenty of them never noticed 1.14.1 before it was gone.&lt;/p&gt;

&lt;p&gt;That cache works the other way round in your visitors' browsers. They're told they can keep the answer for a week (&lt;code&gt;max-age=604800&lt;/code&gt;), so anyone who caught a bad release could keep running it for days after npm pulled it.&lt;/p&gt;

&lt;p&gt;Nobody has to do anything daft to end up floating. hls.js's README says &lt;code&gt;hls.js@1&lt;/code&gt;, Alpine's starts you on &lt;code&gt;alpinejs@3.x.x&lt;/code&gt; (it does say to pin for production, further down), and the first example on Chart.js's getting started page is &lt;code&gt;cdn.jsdelivr.net/npm/chart.js&lt;/code&gt;, with no version at all. Copy the snippet and you're on the newest release forever.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pinning yours
&lt;/h2&gt;

&lt;p&gt;Put the exact version in the URL, and an &lt;code&gt;integrity&lt;/code&gt; hash so the browser refuses the file if it ever changes. jsDelivr's page for each package will copy you the tag with the hash already in:&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;script&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/axios@1.21.0/dist/axios.min.js"&lt;/span&gt;
  &lt;span class="na"&gt;integrity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sha384-p5EwRuz37ylAY9QrjGGIs9ngKVaIwppUFSourks9EIRp1CzoIDWoQkXrHxaAUhEB"&lt;/span&gt;
  &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"anonymous"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;integrity&lt;/code&gt; hash on a floating URL doesn't pin anything. The next release changes the file, the hash stops matching, and the browser won't run it. That beats running a hijacked release, but you'll hear about it from your users first. Or serve the file yourself, which is what a bundler's been doing for you all along.&lt;/p&gt;

&lt;p&gt;To find the floating ones in a page you've already got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://your.site \
  &lt;span class="o"&gt;|&lt;/span&gt; grep -oE &lt;span class="s1"&gt;'(cdn\.jsdelivr\.net/npm|unpkg\.com)/[^"]+'&lt;/span&gt; \
  &lt;span class="o"&gt;|&lt;/span&gt; grep -vE &lt;span class="s1"&gt;'@[0-9]+\.[0-9]+\.[0-9]+'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything it prints doesn't name an exact version. It only sees tags in the HTML though, not ones a script adds later, so open the Network tab, filter on &lt;code&gt;jsdelivr&lt;/code&gt;, and click about a bit. If the URL's got &lt;code&gt;@1&lt;/code&gt;, &lt;code&gt;@latest&lt;/code&gt; or no &lt;code&gt;@&lt;/code&gt; at all, somebody else picks what runs there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ones you can't pin
&lt;/h2&gt;

&lt;p&gt;Of jsDelivr's 200 busiest npm packages, 71 put out a new release in the last month, and at least 22 of those had half their jsDelivr traffic on it within two days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83r9w3bazug0z2bs6bte.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83r9w3bazug0z2bs6bte.png" alt="Share of jsDelivr requests on a package's newest release, two days in" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share of jsDelivr requests on a package's newest release, two days in&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/cdn-floating-tags/handover.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Swiper, three.js, KaTeX and Video.js barely move, so most of their users seem to pin. hls.js is the big one. About 140 million requests a day sit on its newest release, nearly ten times what axios's floaters get these days, and they aren't all hobby pages: urlscan.io has &lt;a href="https://urlscan.io/search/#filename%3A%22hls.js%401%22%20AND%20page.domain%3Aamazon.com" rel="noopener noreferrer"&gt;public scans of Amazon product pages&lt;/a&gt; fetching &lt;code&gt;hls.js@1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A lot of the other fast movers are companies' own browser SDKs, there to be loaded straight off jsDelivr by a snippet the company gives you. GrowthBook's feature flags are at 94% after two days, Zonos's checkout at nearly all of it, SheerID's ID checks at 84%. Meta's Conversions API helper, at 85% of 90 million requests a day, says why in its README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The unversioned URL follows the latest npm release so integrations receive SDK updates&lt;br&gt;
automatically.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/facebook/capi-param-builder/tree/main/client_js" rel="noopener noreferrer"&gt;Meta's capi-param-builder&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is handy. It also means whoever gets into their npm account gets a script tag on your site.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>Why the Wayback keeps saving Anubis's bot check</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Sun, 08 Nov 2026 16:29:09 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/why-the-wayback-keeps-saving-anubiss-bot-check-17h3</link>
      <guid>https://dev.to/notaflightrisk/why-the-wayback-keeps-saving-anubiss-bot-check-17h3</guid>
      <description>&lt;p&gt;Haiku's bug tracker is fine in the Wayback Machine, according to &lt;a href="https://github.com/TecharoHQ/anubis/issues/1994" rel="noopener noreferrer"&gt;a check on Anubis's issue tracker&lt;/a&gt;. Someone tried 22 sites that use Anubis in the Wayback at the start of October, and ticked off the 16 that came out as the real page, Haiku's included. Here's the newest copy the Wayback has of it, from ten days after that:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe85aig8s2da687ts7z6s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe85aig8s2da687ts7z6s.png" alt="The Wayback Machine's toolbar for dev.haiku-os.org, showing 418 captures up to 11 October 2026, above Anubis's page: 'Making sure you're not a bot!' and its cartoon mascot holding a magnifying glass" width="640" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Wayback's newest copy of &lt;a href="https://web.archive.org/web/20261011031612/https://dev.haiku-os.org/" rel="noopener noreferrer"&gt;dev.haiku-os.org&lt;/a&gt;, as of 8 November.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TecharoHQ/anubis" rel="noopener noreferrer"&gt;Anubis&lt;/a&gt; is the proof-of-work page a lot of open source sites put up in 2025 to keep AI scrapers off: GNOME's GitLab, FFmpeg, the Arch wiki, Haiku and plenty more. Its default settings let the Internet Archive straight through, and the check above was done the way everyone does it, with the Wayback's save button. So why's that copy a wall?&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't the Internet Archive's crawler
&lt;/h2&gt;

&lt;p&gt;Click "About this capture" on that copy's banner and it says who collected it: &lt;a href="https://wiki.archiveteam.org/" rel="noopener noreferrer"&gt;ArchiveTeam&lt;/a&gt;, the volunteer group that runs its own crawlers on volunteers' machines and hands what they grab to the Internet Archive. It all goes in the Wayback, next to the Archive's own copies, and you can't tell them apart unless you click.&lt;/p&gt;

&lt;p&gt;Anubis lets the Internet Archive in by its IP addresses. This is the whole rule:&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="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internet-archive&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALLOW&lt;/span&gt;
  &lt;span class="na"&gt;remote_addresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"207.241.224.0/20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"208.70.24.0/21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"2620:0:9c0::/48"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArchiveTeam's crawlers don't come from those addresses. Their URLs project runs on volunteers' own connections and sends a browser's user agent. Since this spring it &lt;a href="https://github.com/ArchiveTeam/urls-grab/blob/master/pipeline.py" rel="noopener noreferrer"&gt;passes itself off as Firefox&lt;/a&gt;, down to the way it sets up the TLS connection:&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="s1"&gt;'--impersonate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'firefox148-h1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anubis's &lt;a href="https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml" rel="noopener noreferrer"&gt;default policy&lt;/a&gt; gives anything that says it's a browser a proof-of-work challenge, which needs JavaScript to solve. The crawler doesn't run JavaScript, so it saves the challenge page as the page. It came back with a 200, so the Wayback files it as a good copy, and the calendar marks the day blue like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The save button always gets in
&lt;/h2&gt;

&lt;p&gt;That's why the check in the issue came out so well. Save Page Now is the Internet Archive's own crawler, from the Archive's own addresses, so it's on the allow list. ArchiveTeam's &lt;a href="https://wiki.archiveteam.org/index.php/Anubis" rel="noopener noreferrer"&gt;own wiki page on Anubis&lt;/a&gt; says as much, "IA SPN is not blocked". So when someone running Anubis checks whether the archive can get in, it can. The broken copies people have reported on Anubis's tracker (&lt;a href="https://github.com/TecharoHQ/anubis/issues/34" rel="noopener noreferrer"&gt;#34&lt;/a&gt;, &lt;a href="https://github.com/TecharoHQ/anubis/issues/1781" rel="noopener noreferrer"&gt;#1781&lt;/a&gt;) got put down to the Archive's addresses being missing from that rule, or out of date, and nobody there has mentioned ArchiveTeam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets the wall
&lt;/h2&gt;

&lt;p&gt;I went through the sites on Anubis's &lt;a href="https://anubis.techaro.lol/docs/user/known-instances" rel="noopener noreferrer"&gt;list of known users&lt;/a&gt;, front pages plus a few deep ones, and opened up to five Wayback copies of each from June 2025 on. Of 345 copies, 140 were Anubis. That gives every address the same five, though, and the busier ones get walled less, so across everything the Wayback holds for them it's more like a quarter. Here's who collected them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevnck0l1r0pgmzt4qt1t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevnck0l1r0pgmzt4qt1t.png" alt="Wayback copies that are an Anubis page, by who collected them" width="799" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wayback copies that are an Anubis page, by who collected them&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/wayback-saves-anubis-walls/collectors.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Copies from June 2025 to November 2026. ArchiveTeam's URLs project 83 of 100, ArchiveBot 33 of 43, Common Crawl 16 of 33, the Internet Archive's own crawlers 8 of 169.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It isn't down to which sites each one happened to visit. Taking just the 50 addresses that have copies from both ArchiveTeam's crawlers and the Archive's, ArchiveTeam's were the wall 95 times in 121, and the Archive's twice in 98. And it's still happening. Since July, 17 of the 18 ArchiveTeam copies I opened were the wall.&lt;/p&gt;

&lt;p&gt;Common Crawl gets walled a lot too, and half of those are Anubis's "Oh noes!" page, the one it shows when a rule turns you away, so I think some of those sites block it on purpose.&lt;/p&gt;

&lt;p&gt;An undated Wayback link, the sort people paste around, opens the newest copy, and for 21 of the addresses that was the wall. I followed an undated link to each of them today, and 11 still land right on it, among them FFmpeg's git, FreeBSD's Subversion browser and the &lt;a href="https://web.archive.org/web/20261009164430/https://azurlane.koumakan.jp/" rel="noopener noreferrer"&gt;Azur Lane wiki&lt;/a&gt;, another one the October check had ticked. The rest have something newer now, like a redirect, or the Wayback wouldn't answer me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a fix?
&lt;/h2&gt;

&lt;p&gt;Not a clean one that I can see. The URLs project looks exactly like Firefox, so there's nothing for a rule to match. ArchiveBot, ArchiveTeam's crawler for whole sites, does say who it is. Its user agent starts &lt;code&gt;ArchiveTeam ArchiveBot/&lt;/code&gt; and goes on &lt;code&gt;and not Mozilla/5.0&lt;/code&gt;, which Anubis's browser rule catches, because it's looking for the word &lt;code&gt;Mozilla&lt;/code&gt;. You could let that user agent through, but so could any scraper that copies it. ArchiveTeam &lt;a href="https://irclogs.archivete.am/archiveteam-bs/2025-07-17" rel="noopener noreferrer"&gt;talked to Anubis's author&lt;/a&gt; about signed crawler identity last year, which would sort it properly, but &lt;a href="https://github.com/TecharoHQ/anubis/issues/744" rel="noopener noreferrer"&gt;it isn't there yet&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run Anubis, look your site up in the Wayback and check who collected the last few copies. And if you're digging through the Wayback for a project's history since 2025, a copy titled "Making sure you're not a bot!" doesn't mean the site was down. Step back a day or two and find one the Internet Archive collected.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>selfhosting</category>
      <category>archiving</category>
    </item>
    <item>
      <title>npm's worms ride a few big accounts, not your dependencies</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:34:26 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/npms-worms-ride-a-few-big-accounts-not-your-dependencies-3fmj</link>
      <guid>https://dev.to/notaflightrisk/npms-worms-ride-a-few-big-accounts-not-your-dependencies-3fmj</guid>
      <description>&lt;p&gt;At 10:41 UTC on 4 August, one npm account published poisoned versions of 132 packages. It took two and a half seconds. Another company's scope had lost 140 the same way two minutes earlier, in three.&lt;/p&gt;

&lt;p&gt;That was ChainDrop, the worm that &lt;a href="https://www.elastic.co/security-labs/threat-command/shai-hulud-chaindrop-npm-supply-chain" rel="noopener noreferrer"&gt;started with keyv&lt;/a&gt;, and the latest of the six big npm worm outbreaks since the first Shai-Hulud in September last year. They all work in roughly the same way. A poisoned package runs a script when you install it, finds an npm token on your machine or in your CI, and uses it to publish poisoned versions of your packages, so whoever installs one of those is next. The write-ups count the damage in hundreds of packages, which makes it sound like it's tearing through everyone.&lt;/p&gt;

&lt;p&gt;I wanted to know who actually published them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr69kxf8cu3eo1mapeqg1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr69kxf8cu3eo1mapeqg1.png" alt="Poisoned npm packages in each outbreak, by the account that published them" width="799" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poisoned npm packages in each outbreak, by the account that published them&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/npm-worms-few-accounts/accounts.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each block is one account, as wide as the number of packages it poisoned. Packages created less&lt;br&gt;
  than 30 days before their bad version are left out, since those are mostly the attackers' own&lt;br&gt;
  throwaways. Mini Shai-Hulud's bar starts from its return in May.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's about 2,000 packages from 175 accounts. Apart from Shai-Hulud 2, each outbreak involved between 5 and 26 accounts, and in four of the six the five busiest accounts put out 94% or more of the packages.&lt;/p&gt;

&lt;p&gt;npm keeps a version's publish time even after it's been pulled, so I grouped the packages by who maintains them and counted each group as one account. &lt;a href="https://packages.ecosyste.ms" rel="noopener noreferrer"&gt;ecosyste.ms&lt;/a&gt; kept a record of who actually published about a third of the bad versions, 230 packages across nearly 70 groups, and every group came back with one publisher. Where there's no record, a group with several maintainers could hide a few more accounts, but even counting every one of them separately, it's 263 accounts rather than 175.&lt;/p&gt;
&lt;h2&gt;
  
  
  The worm takes whatever the account can publish
&lt;/h2&gt;

&lt;p&gt;One account is worth 132 packages because the worm publishes everything the stolen token can reach, up to a limit its author picked. The first two asked npm for the account's packages by searching for &lt;code&gt;maintainer:&lt;/code&gt; and its name. The first Shai-Hulud &lt;a href="https://www.stepsecurity.io/blog/ctrl-tinycolor-and-40-npm-packages-compromised" rel="noopener noreferrer"&gt;asked for 20&lt;/a&gt;, Shai-Hulud 2 &lt;a href="https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/" rel="noopener noreferrer"&gt;asked for 100&lt;/a&gt;, and ChainDrop &lt;a href="https://www.elastic.co/security-labs/threat-command/shai-hulud-chaindrop-npm-supply-chain" rel="noopener noreferrer"&gt;goes for every package&lt;/a&gt; the account can publish.&lt;/p&gt;

&lt;p&gt;You can see the limits in the counts. The first Shai-Hulud's busiest account poisoned exactly 20 packages within a few minutes, then nine more later that day, and the next four did between 15 and 22 at a time. Shai-Hulud 2's busiest did 98 in ten minutes, and five more after that. ChainDrop's two biggest did 140 and 132, all at once.&lt;/p&gt;

&lt;p&gt;With a cap of 100, Shai-Hulud 2 had to reach a lot of accounts to get big, and it did. Without one, an account that looks after a few hundred packages is an outbreak on its own.&lt;/p&gt;

&lt;p&gt;You can run the same search the first two worms did, and it doesn't need a login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s &lt;span class="s1"&gt;'https://registry.npmjs.org/-/v1/search?text=maintainer:YOUR_NAME&amp;amp;size=250'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; jq .total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine says 1, which is the one upside of not having written much.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day Shai-Hulud 2 spread
&lt;/h2&gt;

&lt;p&gt;Shai-Hulud 2 is the one that behaved like the worm you'd picture. It started with AsyncAPI's packages at 03:10 UTC on Monday 24 November 2025, with PostHog's and Postman's among the first after it, and reached 101 accounts by 17:54.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyv3o9qhwq7449lxuzuo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyv3o9qhwq7449lxuzuo.png" alt="New accounts publishing Shai-Hulud 2, by hour" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New accounts publishing Shai-Hulud 2, by hour&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/npm-worms-few-accounts/hours.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;24 November 2025, in UTC. Each account counts once, in the hour it published its first poisoned&lt;br&gt;
  package.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first packages were all gone by lunchtime. PostHog had deleted its by 9:30, Postman by 11:30, and npm pulled AsyncAPI's at 11:46 (&lt;a href="https://www.wiz.io/blog/shai-hulud-2-0-aftermath-ongoing-supply-chain-attack" rel="noopener noreferrer"&gt;Wiz collected the times&lt;/a&gt;). But 9:00 till noon was still the busiest stretch for new accounts. By then it was in other people's packages too, like the account that put out 98 of them just after ten.&lt;/p&gt;

&lt;p&gt;After 17:54, nothing. Nobody's said why. Datadog saw the same cut-off and &lt;a href="https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/" rel="noopener noreferrer"&gt;guessed that npm may have stepped in&lt;/a&gt;, which is as good a guess as any.&lt;/p&gt;
&lt;h2&gt;
  
  
  It didn't go down the dependency tree
&lt;/h2&gt;

&lt;p&gt;This is the bit that surprised me. You'd picture the worm working its way down the dependency tree: my package depends on a poisoned one, I install it, my token goes, and my packages are next.&lt;/p&gt;

&lt;p&gt;So for every account after the first in each outbreak, I took the last clean release it published before its first bad one, and checked whether that depended on anything that was already poisoned, in a version range that would've picked up the bad release. 139 accounts had a clean release to check. If that's how the worm travelled, most of them should've had one.&lt;/p&gt;

&lt;p&gt;Two did. As a placebo, I ran the same check on the same 139 against packages that only went bad later, which can't have caused anything, and got four. For Shai-Hulud 2 I also went a level further down, to the dependencies of those dependencies, and found two more, against none for the placebo. That's chance.&lt;/p&gt;

&lt;p&gt;So whatever got these people, it wasn't the packages they publish depending on a poisoned one. My guess is it was something else they installed, like a dev tool, a CLI installed globally, or an app that never goes on npm, none of which shows up in the published dependency tree. Either way, whether your package depends on something poisoned doesn't tell you if you're next.&lt;/p&gt;
&lt;h2&gt;
  
  
  This year's look like fresh starts
&lt;/h2&gt;

&lt;p&gt;The 2026 outbreaks don't come in one burst. Mini Shai-Hulud hit four SAP packages on 29 April, went quiet, then came back on 11 May with TanStack's and reached 14 accounts in about five hours. A week after that, a single account published 310 AntV packages in six seconds, which &lt;a href="https://safedep.io/mini-shai-hulud-strikes-again-314-npm-packages-compromised/" rel="noopener noreferrer"&gt;SafeDep&lt;/a&gt; says the attacker did with a stolen token. Miasma came in three waves, the last three weeks after the first. Each wave starts with one big account, which looks more like someone kicking it off again than the worm finding its own way there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trusted publishing didn't stop it
&lt;/h2&gt;

&lt;p&gt;TanStack's 42 packages went out in five seconds, published from GitHub Actions through npm's trusted publishing, which swaps a stored npm token for a short-lived one the release workflow gets each time it runs. According to &lt;a href="https://tanstack.com/blog/npm-supply-chain-compromise-postmortem" rel="noopener noreferrer"&gt;TanStack's postmortem&lt;/a&gt;, no npm token was stolen. The attacker poisoned the Actions cache from a pull request, and when the release workflow next ran, the payload read the workflow's token out of the runner's memory. Miasma's biggest account, 49 packages, also published through trusted publishing, going by ecosyste.ms's records.&lt;/p&gt;

&lt;p&gt;So trusted publishing gets rid of stored npm tokens, like the one in &lt;code&gt;~/.npmrc&lt;/code&gt;, which is what the first two worms lived on. But anyone who can run code in your release workflow can still publish as you, and everything that workflow can publish goes out at once.&lt;/p&gt;
&lt;h2&gt;
  
  
  What makes you a dead end
&lt;/h2&gt;

&lt;p&gt;Two settings make you a dead end for some of these. ChainDrop only spread when the stolen token &lt;a href="https://www.elastic.co/security-labs/threat-command/shai-hulud-chaindrop-npm-supply-chain" rel="noopener noreferrer"&gt;could publish without 2FA&lt;/a&gt;, so a token that can't skip 2FA was no use to it. And Shai-Hulud 2 and ChainDrop both started from a &lt;code&gt;preinstall&lt;/code&gt; script, which npm 12 no longer runs for your dependencies until you say so. I tried it with npm 12.0.2 and a package whose &lt;code&gt;preinstall&lt;/code&gt; just creates a file. The file never turned up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   hooky@1.0.0 (preinstall: touch ./RAN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>security</category>
      <category>npm</category>
    </item>
    <item>
      <title>Ubuntu 24.04 gets kernel fixes two months after Debian</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:33:47 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/ubuntu-2404-gets-kernel-fixes-two-months-after-debian-4192</link>
      <guid>https://dev.to/notaflightrisk/ubuntu-2404-gets-kernel-fixes-two-months-after-debian-4192</guid>
      <description>&lt;p&gt;Ubuntu 24.04 still hasn't got the fix for &lt;a href="https://ubuntu.com/security/CVE-2026-98432" rel="noopener noreferrer"&gt;CVE-2026-98432&lt;/a&gt;, a bug in how IPv4 hands over a new TCP connection, which Canonical rates critical and says can lead to remote code execution. It went public on 15 October. Debian 13 shipped the fix the next day. On 24.04 it's listed as pending, in a kernel that's been in &lt;code&gt;-proposed&lt;/code&gt; since 26 October.&lt;/p&gt;

&lt;p&gt;It got a couple of angry blog posts, &lt;a href="https://chalmovsky.com/posts/wtf-ubuntu" rel="noopener noreferrer"&gt;chalmovsky's&lt;/a&gt; (1,600 points &lt;a href="https://news.ycombinator.com/item?id=50337608" rel="noopener noreferrer"&gt;on Hacker News&lt;/a&gt;) and &lt;a href="https://aria.rip/canonical_cve_response/" rel="noopener noreferrer"&gt;Ariadne's&lt;/a&gt;, both about this one CVE. It's not a one-off. I took all 7,823 CVEs the kernel's CVE team has published since 1 March, and for each one found when the fix reached Ubuntu 24.04's security updates (from its security notices) and Debian's (from its security tracker, dated by when the fixed kernel turned up in Debian's security archive).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85zcrkcl156o040syafp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85zcrkcl156o040syafp.png" alt="Kernel CVEs fixed, by how long after they went public" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kernel CVEs fixed, by how long after they went public&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/ubuntu-kernel-fix-wait/fixed.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;CVEs published from March to July 2026, so each has had at least three months. Each line is the share of the CVEs affecting that release which it had fixed by then.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For CVEs from March to July, Debian 13 had already fixed 68% by the day they went public. That's because the kernel's CVE team &lt;a href="https://docs.kernel.org/process/cve.html" rel="noopener noreferrer"&gt;only automatically assigns a CVE once the fix is in a stable kernel&lt;/a&gt;, and Debian 13 ships those stable kernels, so the fix is often out before the CVE has a number. Ubuntu 24.04 had fixed hardly any by then, and only 14% after six weeks.&lt;/p&gt;

&lt;p&gt;Across everything since March, Ubuntu 24.04's median wait is 64 days, counting the CVEs it's fixed so far. On the 2,574 CVEs both have fixed, Debian 13 got there first on 2,558, by a median of 74 days.&lt;/p&gt;

&lt;p&gt;The bad ones don't jump the queue, either. CVEs Canonical itself rates high or critical took a bit longer than the rest. 24.04's default kernel is the slowest Ubuntu one I checked, but not by much: 22.04, 26.04 and the 7.0 HWE kernel you can run on 24.04 have medians of 47 to 53 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the weeks go
&lt;/h2&gt;

&lt;p&gt;Ubuntu 24.04 runs Linux 6.8, which upstream stopped looking after in May 2024. So Canonical maintains it itself, by taking the fixes going into the upstream 6.12 and 6.6 longterm kernels and porting them back in batches, each one tracked as a Launchpad ticket. From March to September, the newest upstream release in each batch was a median 55 days old by the time Canonical opened that ticket. The batch then goes through Canonical's own build-and-test cycle, which took another 20 to 60 days per kernel.&lt;/p&gt;

&lt;p&gt;The old 6.8 base isn't the whole story, though. 22.04 is on 5.15, which upstream still maintains, and Canonical opens a ticket for each 5.15 release within days of it coming out. 22.04 still waits a median of 53 days, so the cycle after the ticket is slow too. Its 5.15.0-198 kernel went into &lt;code&gt;-proposed&lt;/code&gt; on 7 September and reached the security updates on 5 October.&lt;/p&gt;

&lt;p&gt;Here's one batch, the one that brought 6.12.101's fixes to 24.04:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 August&lt;/strong&gt; 6.12.101 comes out upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 August&lt;/strong&gt; Debian 13, which runs 6.12, ships it as a security update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;23 September&lt;/strong&gt; Seven weeks later, Canonical opens &lt;a href="https://bugs.launchpad.net/bugs/2168026" rel="noopener noreferrer"&gt;the ticket&lt;/a&gt; to port it (and 6.6.148) to 24.04.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 September&lt;/strong&gt; The kernel with it, 6.8.0-147, goes into &lt;code&gt;-proposed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13 October&lt;/strong&gt; It reaches 24.04's security updates, 71 days after upstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes arrive in lumps, too. The 13 October update alone brought 1,039 kernel CVEs to 24.04 for the first time, and it's still the newest default kernel 24.04 gets from &lt;code&gt;apt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Debian doesn't queue them. Roughly every two weeks Debian 13 ships the latest upstream 6.12 release, usually within days of it coming out. Debian 12, on the 6.1 longterm kernel, is slower (a median of 11 days) but nowhere near two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's been worse
&lt;/h2&gt;

&lt;p&gt;The kernel CVEs from August to November last year that 24.04 has fixed took a median of 160 days, so this year's a big improvement. Canonical &lt;a href="https://discourse.ubuntu.com/t/going-from-4-to-2-accelerating-the-linux-kernel-release-process/88248" rel="noopener noreferrer"&gt;moved to a two-week kernel cycle&lt;/a&gt; on 28 September, and October's kernel looks different. 6.8.0-149 ports everything up to 6.12.113, which was only five days old when Canonical opened its ticket.&lt;/p&gt;

&lt;p&gt;That doesn't clear the backlog, though. The 1,566 kernel CVEs that 149 fixes for the first time on the default kernel had been public for a median of 39 days by the time it reached &lt;code&gt;-proposed&lt;/code&gt;, and they're still waiting.&lt;/p&gt;

&lt;p&gt;So if you run 24.04 on something exposed, don't take &lt;code&gt;apt upgrade&lt;/code&gt; saying there's nothing to do as meaning you're covered. The CVE's page on &lt;a href="https://ubuntu.com/security/cves" rel="noopener noreferrer"&gt;ubuntu.com/security&lt;/a&gt; says whether your kernel's actually got the fix, and sometimes there's a stopgap. 98432's is an nftables rule that drops incoming IPv4 TCP packets carrying IP options, which Canonical says cuts the risk without closing it.&lt;/p&gt;

&lt;p&gt;There's a quicker kernel on 24.04 right now, too. The 7.0 HWE one (&lt;code&gt;linux-generic-hwe-24.04&lt;/code&gt;) got an update on 2 November with fixes for over 2,300 kernel CVEs published since 8 September, which the default 6.8 kernel is still waiting for. 98432 is pending on that one as well, mind. And if you want kernel fixes the week they're out, you want a distro that ships upstream's releases as they come, like Debian 13.&lt;/p&gt;

&lt;p&gt;I'll run all this again in December, when the CVEs from after the switch have had two months. If the kernel after 149 keeps that five-day queue, half of this post will be out of date, which I'd be fine with.&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>ubuntu</category>
      <category>debian</category>
    </item>
    <item>
      <title>Can the server you SSH into read your screen?</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Thu, 05 Nov 2026 02:11:40 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/can-the-server-you-ssh-into-read-your-screen-1cbh</link>
      <guid>https://dev.to/notaflightrisk/can-the-server-you-ssh-into-read-your-screen-1cbh</guid>
      <description>&lt;p&gt;If you're using Git Bash, WezTerm or the xterm that comes with Debian or Ubuntu 24.04, then yes, it can. Any server you SSH into can read what was on your screen before you connected, and the last file you had open in nano or vim, without you typing a thing.&lt;/p&gt;

&lt;p&gt;Here's a Debian server doing it to Git Bash. The secrets are fake, and everything under the first &lt;code&gt;ssh&lt;/code&gt; line is what the server read back:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5thf1t2ljggqwaklchi5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5thf1t2ljggqwaklchi5.png" alt="A Git Bash window. It shows cat .env printing a database password and a session secret, then ssh deploy@build-box. Below that, the same lines again, read back by the server, then a .pgpass file from nano with another password." width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Git Bash's own window on Windows, SSHing into a Debian box on 4 November.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;6.3m&lt;/strong&gt; downloads of the Git for Windows installer since 28 September, and Git Bash opens in this window unless you pick otherwise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;77s&lt;/strong&gt; for a 50-line bash script to read a whole screen over SSH&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; settings in Git Bash's window that turn it off&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's going on
&lt;/h2&gt;

&lt;p&gt;Terminals understand special codes that programs print to change colours or move the cursor. One of them, from DEC's old VT420 terminals, asks the terminal to add up the characters in a box on the screen and send back the total. It's meant for test suites, so they can check the terminal drew things properly.&lt;/p&gt;

&lt;p&gt;But if the box is one character big, the total is just that character. So a program can ask about each spot on the screen in turn and rebuild the whole thing. Over SSH, the question travels down to your terminal like any other output, and the terminal's reply goes back up as though you'd typed it.&lt;/p&gt;

&lt;p&gt;nano and vim also draw on a second, hidden screen, which stays in memory after you quit, and &lt;code&gt;clear&lt;/code&gt; doesn't wipe it. The server can switch to that screen and read it too, which is how it got the &lt;code&gt;.pgpass&lt;/code&gt; in the picture. You'd see your old file come back up while it's being read, and that's the only sign.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it got here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2018&lt;/strong&gt; xterm puts the code on its list of "dangerous" features that are switched off. Debian builds xterm with an older list of its own, so it's still on in Debian 12, Debian 13 and Ubuntu 24.04.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2019&lt;/strong&gt; WezTerm, and then mintty (the window Git Bash opens in), add the code so they can pass a terminal test suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2023&lt;/strong&gt; &lt;a href="https://dgl.cx/2023/09/ansi-terminal-security" rel="noopener noreferrer"&gt;dgl writes up this exact attack&lt;/a&gt;: reading someone's screen as they SSH in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2024&lt;/strong&gt; WezTerm's last stable release comes out, with the code still on. Homebrew, Scoop and Chocolatey still install that one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026&lt;/strong&gt; WezTerm &lt;a href="https://github.com/wezterm/wezterm/pull/7701" rel="noopener noreferrer"&gt;switches it off&lt;/a&gt; in March, but only in nightly builds. Git Bash still does it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;It's only a problem with servers you don't fully trust. But that's a long list: a shared box, a client's server, or anything that's been broken into, where an attacker can just wait for the next person to log in. And what they get comes from your laptop, not the server: the tokens, passwords and config files you had on screen before you connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking yours
&lt;/h2&gt;

&lt;p&gt;Here's who does it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2ctkhvcffwvufdjmarp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd2ctkhvcffwvufdjmarp.png" alt="Can the server read your screen?" width="800" height="598"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the server read your screen?&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/git-bash-reads-your-screen/terminals.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I tested the first three over SSH on 4 November. The rest are from each project's source code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To check yours, paste this into bash. It clears the screen, prints an X, and asks the terminal what's in the top-left corner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clear&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; X&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;stty -g&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; stty -echo -icanon&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\e[1;1;1;1;1;1*y'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -rs -t 1 -d &lt;span class="s1"&gt;'\' r; stty "$s"; echo "${r:5:4}"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it prints &lt;code&gt;FFA8&lt;/code&gt; or &lt;code&gt;0058&lt;/code&gt;, your terminal just read the X back, and a server could do the same. If it prints nothing or &lt;code&gt;0000&lt;/code&gt;, you're fine.&lt;/p&gt;

&lt;p&gt;If yours read it back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Git Bash, use it inside Windows Terminal instead of its own window (the installer can add a profile for it). Otherwise, open a fresh window before you SSH anywhere you don't trust, because &lt;code&gt;clear&lt;/code&gt; doesn't reach that hidden screen.&lt;/li&gt;
&lt;li&gt;On WezTerm, switch to a nightly build.&lt;/li&gt;
&lt;li&gt;On Debian's or Ubuntu's xterm, add this line to &lt;code&gt;~/.Xresources&lt;/code&gt; and run &lt;code&gt;xrdb -merge ~/.Xresources&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;XTerm*disallowedWindowOps: *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>security</category>
      <category>privacy</category>
      <category>ssh</category>
      <category>windows</category>
    </item>
    <item>
      <title>Wikipedia dropped this UUID in 2014. Now it's everywhere</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:38:59 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/wikipedia-dropped-this-uuid-in-2014-now-its-everywhere-2idc</link>
      <guid>https://dev.to/notaflightrisk/wikipedia-dropped-this-uuid-in-2014-now-its-everywhere-2idc</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;550e8400-e29b-41d4-a716-446655440000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recognise it? GitHub's code search reckons it's in about 303,000 files. That's twice as many as &lt;code&gt;123e4567-e89b-12d3-a456-426614174000&lt;/code&gt;, the other example UUID you've probably seen, and more than any other I tried apart from the all-zeros one. A fresh random one turns up in 0. One app with 2,000 stars even used it as the key that signs its logins.&lt;/p&gt;

&lt;p&gt;It was Wikipedia's example. The &lt;a href="https://en.wikipedia.org/wiki/Universally_unique_identifier" rel="noopener noreferrer"&gt;UUID article&lt;/a&gt; used it from 2006 until 2014, when someone swapped it for the &lt;code&gt;123e4567&lt;/code&gt; one, which MDN uses too. It isn't only Wikipedia's (an &lt;a href="https://www.rfc-editor.org/rfc/rfc7047" rel="noopener noreferrer"&gt;OVSDB RFC&lt;/a&gt; and Rust's &lt;a href="https://docs.rs/uuid" rel="noopener noreferrer"&gt;uuid crate&lt;/a&gt; use it as well), but it's the sort of thing you'd expect to be quietly fading out by now. Wikipedia isn't helping, mind. Someone put it back on the page in 2023 for the URN example, and &lt;code&gt;123e4567&lt;/code&gt; came off in 2024.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nearly all of it is new
&lt;/h2&gt;

&lt;p&gt;I picked 150 repos at random that have it and went back through each file's history to the commit that added it. 147 of them added it in 2025 or 2026, and 113 this year alone.&lt;/p&gt;

&lt;p&gt;Issues and PRs show when it happened:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqykuwiafb7xv0xtv4xyh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqykuwiafb7xv0xtv4xyh.png" alt="GitHub issues and PRs mentioning each UUID, by month" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub issues and PRs mentioning each UUID, by month&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/wikipedias-old-uuid/months.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;These are GitHub's issue search counts for each full string, by the month the issue or PR was opened, from January 2024 to October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Through 2024 it trailed &lt;code&gt;123e4567&lt;/code&gt;. Then it crossed over in mid-2025, peaked in March this year at 470 a month, and has nearly halved since. Something started handing it out again.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I asked them
&lt;/h2&gt;

&lt;p&gt;I gave Claude and Codex the same three requests, three times each: an example UUID, a JSON order object with a UUID id, and a Python test fixture with a hardcoded UUID. That's nine answers per model, on 3 November, with Codex running gpt-5.6-sol:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;What it gave me&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;550e8400&lt;/code&gt; 8 times, something else once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;550e8400&lt;/code&gt; all 9 times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;550e8400&lt;/code&gt; for the examples, &lt;code&gt;123e4567&lt;/code&gt; for tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;td&gt;a made-up &lt;code&gt;3f...&lt;/code&gt; one all 9 times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;a made-up &lt;code&gt;3f...&lt;/code&gt; one 6 times, &lt;code&gt;f47ac10b...&lt;/code&gt; 3 times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 5.5&lt;/td&gt;
&lt;td&gt;a made-up &lt;code&gt;3f...&lt;/code&gt; one 5 times, &lt;code&gt;123e4567&lt;/code&gt; 3 times&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 4.5 models hand out Wikipedia's old one nearly every time. But the curve crossed over before either of them came out, so I'd guess the Claude 4 models had the habit too. Sonnet 4 has been retired, so I can't ask it. Of the 150 commits I dated, 22 had Claude Code's co-author line on them, against 12 of 150 for &lt;code&gt;123e4567&lt;/code&gt;. That's a lean rather than proof, since Claude Code adds that line by default, so its work is easy to spot and other tools' might not be.&lt;/p&gt;

&lt;p&gt;Why that one and not &lt;code&gt;123e4567&lt;/code&gt;, I can't tell you. It wasn't the more common of the two in code before 2025, so it's not just that there was more of it to learn from. Its return to Wikipedia in 2023 might have helped, but that's a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude's moved on
&lt;/h2&gt;

&lt;p&gt;The newer Claude models never gave me &lt;code&gt;550e8400&lt;/code&gt; at all. They mostly make one up, and they make up much the same one. 20 of their 27 answers started with &lt;code&gt;3f&lt;/code&gt;, and 12 with exactly &lt;code&gt;3f2b8c1e&lt;/code&gt; or &lt;code&gt;3f8a2c1e&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3f2b8c1e-9a4d-4e7b-8c21-5d6f0a9b7e43
3f2b8c1e-9a4d-4e7b-8e2a-6c5d0b1a9f47
3f2b8c1e-7a4d-4e9b-9c15-2d6f0a8b4e71
3f8a2c1e-7b4d-4e9a-a6c2-91d5e0b7f348
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The start sticks, more or less, and the end wanders off. The first 18 characters of the top one, &lt;code&gt;3f2b8c1e-9a4d-4e7b&lt;/code&gt;, are already in about 370 files on GitHub, across at least 124 repos. I dated 50 of those, and every one added it in 2026, 21 of them with Claude Code's co-author line.&lt;/p&gt;

&lt;p&gt;So if you find a &lt;code&gt;550e8400&lt;/code&gt; in a repo you've inherited, it was probably written in the last couple of years, quite possibly by a 4.5 model or Codex, and a &lt;code&gt;3f2b8c1e-9a4d&lt;/code&gt; points at a newer Claude. More of a tell than I expected a UUID to be.&lt;/p&gt;

&lt;p&gt;The other one I'd learn is &lt;code&gt;3fa85f64-5717-4562-b3fc-2c963f66afa6&lt;/code&gt;, in about 40,000 files. It comes from &lt;a href="https://github.com/swagger-api/swagger-ui/blob/main/src/core/plugins/json-schema-5-samples/fn/index.js" rel="noopener noreferrer"&gt;Swagger UI's example generator&lt;/a&gt;, which fills in example requests in API docs, so if it turns up in your logs, it's usually someone sending the example from your docs as it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the example is the secret
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/apconw/Aix-DB" rel="noopener noreferrer"&gt;Aix-DB&lt;/a&gt;, an app for asking your database questions in plain English, used this for its login tokens:&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="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"JWT_SECRET_KEY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"550e8400-e29b-41d4-a716-446655440000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From release 1.2.0 its example config suggested this as the key, and from 1.2.2 the code fell back to it whenever &lt;code&gt;JWT_SECRET_KEY&lt;/code&gt; wasn't set, which its Docker Compose file didn't do. So a default install signed its tokens with a key that's in 300,000 public files, and anyone could have made their own token with the admin role in it.&lt;/p&gt;

&lt;p&gt;An &lt;a href="https://github.com/apconw/Aix-DB/pull/242" rel="noopener noreferrer"&gt;automated security PR&lt;/a&gt; took the fallback out on 10 October, and the Compose file now refuses to start without a key. There hasn't been a release since, though, so 1.2.4 is still the latest. I found a couple more, in tiny repos and still live, so no names.&lt;/p&gt;

&lt;p&gt;Opus 5.5's own answer, when I asked it for an example UUID, came with a warning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I made it up by hand rather than generating it, so don't use it anywhere uniqueness matters - for that, run &lt;code&gt;uuidgen&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair enough, coming from the family that's spreading &lt;code&gt;3f2b8c1e&lt;/code&gt; round GitHub.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>security</category>
      <category>uuid</category>
    </item>
    <item>
      <title>Whoever takes your old GitHub name gets your MCP namespace</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:48:35 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/whoever-takes-your-old-github-name-gets-your-mcp-namespace-50ea</link>
      <guid>https://dev.to/notaflightrisk/whoever-takes-your-old-github-name-gets-your-mcp-namespace-50ea</guid>
      <description>&lt;p&gt;Rename your GitHub account and your MCP servers stay in the official registry under your old name. Whoever registers that name next can publish new versions of them, and the registry can't tell it isn't you.&lt;/p&gt;

&lt;p&gt;On 2 November I checked every &lt;code&gt;io.github&lt;/code&gt; name in the registry against GitHub. At least 111 names that published servers have since been renamed or deleted, and don't belong to any account now. Between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;145&lt;/strong&gt; live servers are still listed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17,000&lt;/strong&gt; or so times a week, their npm and PyPI packages get downloaded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;49&lt;/strong&gt; of them only have a remote URL, so there's no package to check at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They're not toys, either. Here are the busiest:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwo8qs6r660s6w1zj4kv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwo8qs6r660s6w1zj4kv.png" alt="Weekly downloads of the busiest servers under unclaimed names" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weekly downloads of the busiest servers under unclaimed names&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/whoever-takes-your-github-name/busiest.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;These are npm and PyPI downloads over the last week, as of 2 November. Each one is listed in the registry under a GitHub name that nobody holds now.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aiterm-mcp&lt;/code&gt; lets your coding agent drive terminals, &lt;code&gt;agents-fleet&lt;/code&gt; reaches every machine you've got over SSH, the Shodan and VirusTotal ones run with your API keys for those services, and &lt;code&gt;azure-board-mcp&lt;/code&gt; wants a personal access token. Further down the list, four more handle crypto wallets, payments or transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;The registry hands out &lt;code&gt;io.github.&amp;lt;login&amp;gt;/*&lt;/code&gt; based on your GitHub login, not your account's ID, which is the bit that never changes (&lt;a href="https://github.com/modelcontextprotocol/registry/blob/main/internal/api/handlers/v0/auth/github_at.go" rel="noopener noreferrer"&gt;&lt;code&gt;github_at.go&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you rename, GitHub's docs say your old name &lt;a href="https://docs.github.com/en/account-and-profile/concepts/username-changes" rel="noopener noreferrer"&gt;becomes available for anyone else to claim&lt;/a&gt;. Whoever claims it gets your namespace, and from there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a new version is always active, and the highest one becomes &lt;code&gt;latest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;deprecating your old entry doesn't stop it (a maintainer &lt;a href="https://github.com/modelcontextprotocol/registry/issues/1662#issuecomment-6376241397" rel="noopener noreferrer"&gt;said as much&lt;/a&gt; in October)&lt;/li&gt;
&lt;li&gt;the package check only asks for a package that carries the server's name, and it doesn't have to be the one you published&lt;/li&gt;
&lt;li&gt;a remote server has no package to check, so its next version can point at any URL&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What a bad version gets
&lt;/h2&gt;

&lt;p&gt;A local MCP server runs as you, with whatever keys you put in its config. A remote one sees every tool call your agent makes to it, and gets to write the answers.&lt;/p&gt;

&lt;p&gt;If you installed the npm or PyPI package directly, a new registry version doesn't touch you. It's the clients and galleries that pick servers from the registry by name that would follow it, and the registry tells &lt;a href="https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/registry-aggregators.mdx" rel="noopener noreferrer"&gt;aggregators&lt;/a&gt; to copy it and keep their own copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Names are already changing hands
&lt;/h2&gt;

&lt;p&gt;22 namespaces in the registry now belong to a GitHub account that was created after they were published. &lt;code&gt;io.github.facebook&lt;/code&gt; is one, since Meta became &lt;code&gt;meta&lt;/code&gt; on 28 October. So far most of them look like owners holding on to their old name, and none has published anything.&lt;/p&gt;

&lt;p&gt;The other half has bitten for real. About twenty publishers have filed tickets since April because they're locked out of their own old entries, like &lt;a href="https://github.com/modelcontextprotocol/registry/issues/1662" rel="noopener noreferrer"&gt;PaidSync&lt;/a&gt;, whose old listing kept advertising an address they'd switched off until a maintainer stepped in.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you've renamed
&lt;/h2&gt;

&lt;p&gt;Register your old name again today. An empty org costs nothing. Once you hold the name, nobody else can publish there, and you can deprecate the old entries yourself.&lt;/p&gt;

&lt;p&gt;If you install MCP servers, pin the package and version rather than trusting the registry name, and treat a change of package or URL between versions like a new server.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>github</category>
      <category>mcp</category>
    </item>
    <item>
      <title>VS Code remembers the commands you hid with a space</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Sun, 01 Nov 2026 20:04:02 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/vs-code-remembers-the-commands-you-hid-with-a-space-4ei7</link>
      <guid>https://dev.to/notaflightrisk/vs-code-remembers-the-commands-you-hid-with-a-space-4ei7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsg22jj52p99i8el47fxw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsg22jj52p99i8el47fxw.png" alt="VS Code's Run Recent Command list. Under 'previous session': uptime, ls -lh, cd /var/log, and ' export GITHUB_TOKEN=ghp_FAKE_DO_NOT_USE_1101' with its leading space. Under 'bash history ~/.bash_history': code ., npm run dev, git status" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;VS Code's Run Recent Command list, in a new window on my laptop. Note the space before &lt;code&gt;export&lt;/code&gt;. The token's a fake.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The top four were typed on a different machine. I'd opened a Debian server with Remote-SSH and put a space in front of the export, so the server's bash would skip it, and it did. The server's &lt;code&gt;~/.bash_history&lt;/code&gt; has &lt;code&gt;cd /var/log&lt;/code&gt;, &lt;code&gt;ls -lh&lt;/code&gt; and &lt;code&gt;uptime&lt;/code&gt;, and no token. My laptop has it, and it's offering to run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The space trick
&lt;/h2&gt;

&lt;p&gt;If you've ever pasted a token into a terminal, someone's probably told you to stick a space in front. bash won't save a line that starts with one when &lt;code&gt;HISTCONTROL&lt;/code&gt; has &lt;code&gt;ignorespace&lt;/code&gt; or &lt;code&gt;ignoreboth&lt;/code&gt; in it, which Debian and Ubuntu set up for you, and zsh does the same with &lt;code&gt;setopt HIST_IGNORE_SPACE&lt;/code&gt;. It's the &lt;a href="https://unix.stackexchange.com/questions/49214/how-to-remove-a-single-line-from-history" rel="noopener noreferrer"&gt;accepted answer&lt;/a&gt; on unix.SE, along with &lt;code&gt;history -d&lt;/code&gt; for when you forgot.&lt;/p&gt;

&lt;p&gt;Both of those only change what the shell writes down, though, and VS Code keeps its own list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes instead
&lt;/h2&gt;

&lt;p&gt;VS Code injects a shell integration script into bash and zsh, on by default. Among other things it reads each command as it runs, and a leading space doesn't stop it. When the command finishes, anything that isn't blank goes into a list of your last 100, which VS Code keeps in a SQLite file in its user folder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~/.config/Code/User

&lt;ul&gt;
&lt;li&gt;globalStorage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;state.vscdb&lt;/strong&gt; &lt;em&gt;commands&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;state.vscdb.backup&lt;/li&gt;
&lt;li&gt;workspaceStorage&lt;/li&gt;
&lt;li&gt;1d6fb3dd...

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;state.vscdb&lt;/strong&gt; &lt;em&gt;scrollback&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;state.vscdb.backup&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's Linux. On a Mac the &lt;a href="https://code.visualstudio.com/docs/configure/settings#_settings-file-locations" rel="noopener noreferrer"&gt;user folder&lt;/a&gt; is &lt;code&gt;~/Library/Application Support/Code/User&lt;/code&gt; and on Windows it's &lt;code&gt;%APPDATA%\Code\User&lt;/code&gt;, but I only ran it on Linux. Cursor and Windsurf (which now ships as Devin) are built on VS Code and keep the same list in their own user folders. I read their code rather than running them, so don't quote me, but the only difference I spotted is that Cursor's bash check doesn't know &lt;code&gt;ignorespace&lt;/code&gt; on its own. There, only &lt;code&gt;ignoreboth&lt;/code&gt; and zsh get caught.&lt;/p&gt;

&lt;p&gt;I tried bash with &lt;code&gt;ignoreboth&lt;/code&gt; and zsh with &lt;code&gt;HIST_IGNORE_SPACE&lt;/code&gt; on VS Code 1.144.0 on 1 November. Both times the shell's history file was clean and &lt;code&gt;state.vscdb&lt;/code&gt; wasn't. A line I'd taken out with &lt;code&gt;history -d&lt;/code&gt; was still in VS Code's list too, since that only edits bash's.&lt;/p&gt;

&lt;p&gt;To see your own list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 ~/.config/Code/User/globalStorage/state.vscdb \
  &lt;span class="s2"&gt;"select value from ItemTable where key = 'terminal.history.entries.commands'"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; jq -r &lt;span class="s1"&gt;'.entries[].key'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second copy is the scrollback. VS Code saves each local terminal's contents so it can bring them back after a restart, which is the command plus whatever it printed. It saves the environment that terminal started with as well, so if VS Code itself was started from a shell where you'd exported a token, the token's in that file too, space or no space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over SSH, it comes home
&lt;/h2&gt;

&lt;p&gt;Nothing in the command list says which machine a command ran on, so with Remote-SSH, what you type on the server is saved on your laptop, hidden ones included. I searched the whole home folder on the server afterwards, VS Code's own &lt;code&gt;~/.vscode-server&lt;/code&gt; included, and the token wasn't anywhere.&lt;/p&gt;

&lt;p&gt;So the copy ends up on your laptop, the one that gets backed up. It shouldn't go any further with Settings Sync, at least: the code saves the list as machine-only, which Sync skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting it
&lt;/h2&gt;

&lt;p&gt;Every entry in Run Recent Command has an X, and there's a &lt;code&gt;Terminal: Clear Previous Session History&lt;/code&gt; command. Both take it off the list, and neither takes it off the disk. SQLite marks the space as free and leaves the bytes alone, so &lt;code&gt;grep -a&lt;/code&gt; still found the token in &lt;code&gt;state.vscdb&lt;/code&gt; and its &lt;code&gt;.backup&lt;/code&gt; afterwards. Clear doesn't touch the scrollback copy at all.&lt;/p&gt;

&lt;p&gt;With VS Code closed, &lt;code&gt;sqlite3 state.vscdb VACUUM&lt;/code&gt; rewrites the file, and the leftovers went with it. If it was a real token though, rotate it. That's quicker than chasing every backup it's been copied into since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it out
&lt;/h2&gt;

&lt;p&gt;Two settings, the first for the command list and the second for the scrollback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"terminal.integrated.shellIntegration.history"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nl"&gt;"terminal.integrated.enablePersistentSessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one means your terminals don't come back after a restart. With both on a fresh profile, the token wasn't anywhere in VS Code's folder. Setting the first empties the list you've already got, but turning the second off leaves any scrollback that's already saved.&lt;/p&gt;

&lt;p&gt;Better still, keep secrets off the command line. &lt;code&gt;read -rs GITHUB_TOKEN&lt;/code&gt; asks for it without echoing, so VS Code's list gets &lt;code&gt;read -rs GITHUB_TOKEN&lt;/code&gt; and never the token.&lt;/p&gt;

&lt;p&gt;VS Code does know the trick, for what it's worth. The same shell integration script sets &lt;code&gt;HISTCONTROL=ignorespace&lt;/code&gt; for a special case, under the comment &lt;code&gt;# Prevent AI-executed commands from polluting shell history&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>vscode</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
