<?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>PHP says to count form parts at the proxy. It can't</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:25:44 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/php-says-to-count-form-parts-at-the-proxy-it-cant-14bl</link>
      <guid>https://dev.to/notaflightrisk/php-says-to-count-form-parts-at-the-proxy-it-cant-14bl</guid>
      <description>&lt;p&gt;PHP shipped fixes on 21 October for PHP2Shell, two bugs that let a request from anyone run code on the server. Updating is the fix. For anyone who can't yet, the &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;php.net announcement&lt;/a&gt; has a list of stopgaps, and one of them is aimed at whatever sits in front of PHP:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Consider enforcing limits at the web server or proxy level for decoded request size, input field&lt;br&gt;
count, multipart part count, header count, and nesting depth. These limits must also cover&lt;br&gt;
requests without a usable Content-Length.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;php.net, 21 October&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first bug needs PHP to run out of memory partway through a request, and an upload form with loads of parts, or loads of headers inside one part, is a cheap way to get it there. The &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;GitHub advisory&lt;/a&gt; says to keep these limits even after patching, as they "reduce hard OOM and denial of service risk".&lt;/p&gt;

&lt;p&gt;I went looking for the nginx setting that counts parts, and there isn't one. So I put nginx, Apache httpd, Caddy, HAProxy and Traefik in front of a little Python backend that just counts what turns up, plus the two open-source WAFs, ModSecurity and Coraza, each with the OWASP Core Rule Set. Then I sent the same harmless forms through all of them: 20,000 one-letter fields, 5,000 empty file inputs, a field name 100 brackets deep, a part with 10,000 made-up headers and so on. Nothing vulnerable behind any of 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%2F61z7r8yx47dw4mmbbmdl.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%2F61z7r8yx47dw4mmbbmdl.png" alt="What each layer stopped" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What each layer stopped&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/proxies-cannot-count-parts/limits.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Multipart forms, stock settings plus a size cap, 23 October. Both WAFs run the Core Rule Set. A&lt;br&gt;
  tick or a number means it stopped forms past that. Headers are the ones inside one part, depth is&lt;br&gt;
  how many brackets deep a field name goes, and ModSecurity's parts leave out real file uploads. PHP&lt;br&gt;
  is 8.4.27, and rather than refuse the request it just drops whatever's over.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Size is the bit they can all do (mostly)
&lt;/h2&gt;

&lt;p&gt;Every proxy has a body size limit, and that's where most of the advice I've read stops. nginx, Caddy and Traefik all turned away a 12 MB body with an 8 MB cap, with a &lt;code&gt;Content-Length&lt;/code&gt; or chunked.&lt;/p&gt;

&lt;p&gt;HAProxy's usual size check is a deny on the &lt;code&gt;Content-Length&lt;/code&gt; header, which is what I used, and a chunked request doesn't have one, so the 12 MB walked through. Its own &lt;a href="https://github.com/haproxy/haproxy/blob/v3.4.0/doc/configuration.txt" rel="noopener noreferrer"&gt;manual&lt;/a&gt; says &lt;code&gt;req.body_size&lt;/code&gt; only sees what's in the buffer when the body's chunked. That's exactly the "without a usable Content-Length" case php.net asks you to cover.&lt;/p&gt;
&lt;h2&gt;
  
  
  Apache's size cap skips php-fpm
&lt;/h2&gt;

&lt;p&gt;Apache was the odd one. &lt;code&gt;LimitRequestBody&lt;/code&gt; is its size cap, and through &lt;code&gt;ProxyPass&lt;/code&gt; the 12 MB went straight past an 8 MB limit. Same with php-fpm behind &lt;code&gt;SetHandler "proxy:fcgi://..."&lt;/code&gt;. Here's httpd 2.4.69 with &lt;code&gt;LimitRequestBody 8388608&lt;/code&gt;, a text file, and a PHP script that prints how much body it got:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @12mb.bin localhost:8096/plain.txt
413
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @12mb.bin localhost:8096/len.php
&lt;span class="nv"&gt;cl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12582912 &lt;span class="nv"&gt;got&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12582912
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same server, same limit. The text file gets a 413, and PHP gets all 12 MB.&lt;/p&gt;

&lt;p&gt;The docs used to warn you. Up to 2.4.53 they said &lt;code&gt;LimitRequestBody&lt;/code&gt; had "limited applicability to proxy requests". 2.4.54, the release that set the default to 1 GB, took that line out, but 2.4's code still switches the limit off for any proxied request. The fix for that went into trunk &lt;a href="https://github.com/apache/httpd/commit/a3eef41" rel="noopener noreferrer"&gt;in 2018&lt;/a&gt; and hasn't come back to 2.4.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;mod_php&lt;/code&gt; there's no proxying, so the limit works, and that's the setup php.net rates riskiest. With php-fpm behind Apache, your real size cap is PHP's own &lt;code&gt;post_max_size&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP counts most of it already
&lt;/h2&gt;

&lt;p&gt;php.net never mentions it, but PHP has its own setting for everything on that list except the headers, and the counts are on by default. &lt;code&gt;max_input_vars&lt;/code&gt; is 1,000, &lt;code&gt;max_input_nesting_level&lt;/code&gt; is 64, and &lt;code&gt;max_multipart_body_parts&lt;/code&gt; works out as &lt;code&gt;max_input_vars&lt;/code&gt; plus &lt;code&gt;max_file_uploads&lt;/code&gt;, so 1,020. Here's PHP 8.4.27 getting a 5,000-field form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: PHP Request Startup: Input variables exceeded 1000. To increase
the limit change max_input_vars in php.ini.
Warning: PHP Request Startup: Multipart body parts limit exceeded 1020.
To increase the limit change max_multipart_body_parts in php.ini.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It keeps the first thousand and carries on. The advisory says the working exploit ran "with default PHP input and memory limits", so the defaults clearly aren't low enough, and it asks you to lower nesting and bound the rest. Turn them down to what your app actually takes. With these, PHP kept 100 fields and dropped the other 4,900:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;max_input_vars&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt;
&lt;span class="py"&gt;max_multipart_body_parts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt;
&lt;span class="py"&gt;max_input_nesting_level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Headers in a part is the one PHP has no setting for. The patched releases only keep two from each part, &lt;code&gt;Content-Disposition&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt;, so after updating it mostly stops being PHP's problem. Until then, it's down to whatever's in front.&lt;/p&gt;

&lt;h2&gt;
  
  
  A WAF needs telling
&lt;/h2&gt;

&lt;p&gt;None of the five proxies looks inside a form at all, which makes sense, as none of them parse one. The WAFs do, but not for this.&lt;/p&gt;

&lt;p&gt;ModSecurity turned away the 5,000- and 20,000-field forms with "Failed to fully parse request body due to large argument count", a rule in its own recommended config that trips at 1,000 fields. The Core Rule Set has a field-count rule too, but it's off until you set a number. Empty file inputs count as fields, so 5,000 of those got stopped as well. Headers in a part it doesn't count at all, and one part with 10,000 of them went through with every header delivered.&lt;/p&gt;

&lt;p&gt;Coraza (inside Caddy) let all 20,000 fields through. The only thing that stopped a pile of headers was Go's own multipart reader, which gives up past 10,000 in one part, and 20 parts of 9,000 each got through fine.&lt;/p&gt;

&lt;p&gt;Both will count them if you ask, though. Their rule language has &lt;code&gt;&amp;amp;MULTIPART_PART_HEADERS&lt;/code&gt;, the number of part headers in the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;SecRule &amp;amp;MULTIPART_PART_HEADERS "@gt 500" \
    &lt;span class="err"&gt;"&lt;/span&gt;id:1001,phase:2,deny,status:413,msg:'too many part headers'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stopped 1,000 headers in one part on both WAFs. It counts every part header in the whole request rather than per part, so three parts of 200 trips it too.&lt;/p&gt;

&lt;p&gt;That works in your favour, since every part has at least one header, so it caps parts as well. Coraza went from letting 5,000 parts through to a 413. A file input sends two headers (&lt;code&gt;Content-Disposition&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt;), so set it a bit over twice the fields in your biggest real form.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/socfortress/waf-platform-public/tree/main/rule-packs/php2shell" rel="noopener noreferrer"&gt;SOCFortress&lt;/a&gt; published the same idea yesterday in their PHP2Shell rule pack, set at 100 and only for multipart, along with one for nesting. I tried a version of that one on both WAFs too, and it blocked a field name 12 brackets deep and let 11 through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;SecRule ARGS_POST_NAMES|FILES_NAMES "@rx (?:\[[^\]]*\]){12}" \
    &lt;span class="err"&gt;"&lt;/span&gt;id:1002,phase:2,deny,status:413,msg:'name nested too deep'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  nginx can count, in JavaScript
&lt;/h2&gt;

&lt;p&gt;nginx has no directive for any of it, but &lt;a href="https://nginx.org/en/docs/njs/reference.html" rel="noopener noreferrer"&gt;njs&lt;/a&gt;, nginx's own JavaScript module, got a form parser in 0.9.9, and &lt;code&gt;js_access&lt;/code&gt; runs it before the request goes anywhere. This lets everything through except a multipart form it won't parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headersIn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^multipart&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;form-data/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readRequestForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;maxKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`form rejected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;413&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;load_module&lt;/span&gt; &lt;span class="nc"&gt;modules/ngx&lt;/span&gt;&lt;span class="s"&gt;_http_js_module.so&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;js_import&lt;/span&gt; &lt;span class="s"&gt;gate&lt;/span&gt; &lt;span class="s"&gt;from&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/gate.js&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;8m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kn"&gt;js_access&lt;/span&gt; &lt;span class="s"&gt;gate.form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On nginx 1.31.8 with njs 1.0.2 it let 200 fields through and turned 201 away, and the form still reached the backend whole after njs had read it. It only looks at multipart, like the WAF rule, so a big urlencoded form is still PHP's &lt;code&gt;max_input_vars&lt;/code&gt; to deal with. It does nothing about nesting either, since &lt;code&gt;a[x][x][x]&lt;/code&gt; is just a name to it.&lt;/p&gt;

&lt;p&gt;The headers took care of themselves. njs won't take more than 32 in a part (&lt;code&gt;Content-Disposition&lt;/code&gt; included), so 33 gets a 413 without you asking, and that's baked into the parser rather than a setting. It's a separate module, &lt;code&gt;nginx-module-njs&lt;/code&gt;, which the official Docker image already has, apart from the &lt;code&gt;-slim&lt;/code&gt; ones.&lt;/p&gt;

</description>
      <category>security</category>
      <category>php</category>
      <category>nginx</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Pickle-only uploads to Hugging Face doubled since 2024</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:54:59 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/pickle-only-uploads-to-hugging-face-doubled-since-2024-4j31</link>
      <guid>https://dev.to/notaflightrisk/pickle-only-uploads-to-hugging-face-doubled-since-2024-4j31</guid>
      <description>&lt;p&gt;Hugging Face is getting more pickle files, not fewer. In the second half of 2024, about one in seventeen people uploading model weights each month put up nothing but pickles. Since March it's been about one in eight.&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%2Fp0xaiilt5w56bmp6zyic.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%2Fp0xaiilt5w56bmp6zyic.png" alt="Pickle-only share of new Hugging Face models" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pickle-only share of new Hugging Face models&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/new-hf-models-still-pickle/monthly.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each uploader counted once a month. Pickle only means &lt;code&gt;.pt&lt;/code&gt;, &lt;code&gt;.pth&lt;/code&gt;, &lt;code&gt;.ckpt&lt;/code&gt; or &lt;code&gt;.pkl&lt;/code&gt; with no&lt;br&gt;
  safetensors, GGUF or ONNX alongside, ignoring trainer leftovers. Course and swarm uploads left&lt;br&gt;
  out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That matters because a pickle runs code when you load it, and &lt;a href="https://huggingface.co/docs/safetensors/index" rel="noopener noreferrer"&gt;safetensors&lt;/a&gt; doesn't. Safetensors is what &lt;code&gt;transformers&lt;/code&gt; writes, and the old pickles on the Hub are meant to be leftovers from before it caught on.&lt;/p&gt;

&lt;p&gt;The numbers come from &lt;a href="https://huggingface.co/datasets/cfahlgren1/hub-stats" rel="noopener noreferrer"&gt;hub-stats&lt;/a&gt;, a daily dump of every public repo on the Hub with its file list, as of 21 October. I counted uploaders rather than repos, because one person can push a thousand repos in an afternoon. I also left out the Hugging Face RL course's LunarLander and Huggy models, and Gensyn's BlockAssist, which had over 9,000 people uploading pickled checkpoints from the same app last autumn. Leave those in and October 2025 reads 32%.&lt;/p&gt;

&lt;p&gt;It peaked at 15% in May and has slipped every month since, which I'll come back to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Most of them load with weights_only
&lt;/h2&gt;

&lt;p&gt;Since &lt;a href="https://dev-discuss.pytorch.org/t/bc-breaking-change-torch-load-is-being-flipped-to-use-weights-only-true-by-default-in-the-nightlies-after-137602/2573" rel="noopener noreferrer"&gt;PyTorch 2.6&lt;/a&gt;, &lt;code&gt;torch.load&lt;/code&gt; only unpickles tensors and a short list of harmless types unless you tell it otherwise. If these files load that way, the pickle doesn't really matter.&lt;/p&gt;

&lt;p&gt;I took one pickle-only repo with a PyTorch checkpoint (&lt;code&gt;.pt&lt;/code&gt;, &lt;code&gt;.pth&lt;/code&gt; or &lt;code&gt;.ckpt&lt;/code&gt;) from each of 114 uploaders, picked at random from everything created between August and 21 October, and read the pickle inside the biggest checkpoint without running it. That's just Python's &lt;code&gt;pickletools&lt;/code&gt; over a few ranged requests, so no downloading gigabytes and nothing gets executed. Then I checked everything it asks to import against &lt;a href="https://github.com/pytorch/pytorch/blob/main/torch/_weights_only_unpickler.py" rel="noopener noreferrer"&gt;torch's list&lt;/a&gt;.&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%2Fhdgurwknqj7vef37eu89.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%2Fhdgurwknqj7vef37eu89.png" alt="What's inside 114 new pickle-only models" width="799" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's inside 114 new pickle-only models&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/new-hf-models-still-pickle/loads.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One repo per uploader, read on 22 October. Each square is about one percent. Tensors only means it&lt;br&gt;
  loads with &lt;code&gt;weights_only=True&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;89 of them are a &lt;code&gt;state_dict&lt;/code&gt; and maybe an epoch number, and load fine with &lt;code&gt;weights_only&lt;/code&gt; on.&lt;/p&gt;

&lt;p&gt;The other 25 won't load. Ten of those are over something boring, like a numpy float for the best accuracy or the &lt;code&gt;argparse&lt;/code&gt; settings from training. Here's torch 2.14 on the numpy one, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Weights only load failed. [...]
  (1) [...] Re-running `torch.load`
  with `weights_only` set to `False`
  will likely succeed, but it can
  result in arbitrary code execution.
  [...]
  (2) Alternatively, to load with
  `weights_only=True` please check
  the recommended steps in the
  following error message.
  WeightsUnpickler error: Unsupported
  global: GLOBAL
  numpy._core.multiarray.scalar was
  not an allowed global by default.
  [...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proper fix is option two, &lt;code&gt;torch.serialization.add_safe_globals([...])&lt;/code&gt; with the one numpy type it names. Option one is turning the protection off, it's listed first and it says it'll "likely succeed". I'd guess that's the one most people pick, but I've no way of checking that.&lt;/p&gt;

&lt;p&gt;Another seven pickle a class from whatever they were trained with, like a LeRobot policy config, a DeepSpeed object or a model class from the uploader's own code, so they won't load at all without that code installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  YOLO turns it off for you
&lt;/h2&gt;

&lt;p&gt;The last eight are YOLO models pickled whole, Ultralytics classes and all, usually called &lt;code&gt;best.pt&lt;/code&gt;. My favourite spots Xbox controllers. You'd load one with &lt;code&gt;YOLO("best.pt")&lt;/code&gt;, and you never see torch's error, because &lt;a href="https://github.com/ultralytics/ultralytics/blob/main/ultralytics/nn/tasks.py" rel="noopener noreferrer"&gt;Ultralytics' loader&lt;/a&gt; calls &lt;code&gt;torch.load&lt;/code&gt; with &lt;code&gt;weights_only=False&lt;/code&gt; unless you've asked otherwise. Since June there's been &lt;a href="https://github.com/ultralytics/ultralytics/pull/24829" rel="noopener noreferrer"&gt;an opt-in safe mode&lt;/a&gt; that loads them with &lt;code&gt;weights_only=True&lt;/code&gt; and an allowlist of Ultralytics' own classes:&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;ULTRALYTICS_SAFE_LOAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 python detect.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, a stranger's &lt;code&gt;best.pt&lt;/code&gt; runs whatever's in it, on the latest torch and the latest Ultralytics.&lt;/p&gt;

&lt;p&gt;None of the 114 files I read asks for anything nasty like &lt;code&gt;os&lt;/code&gt; or &lt;code&gt;eval&lt;/code&gt;. It's people saving whatever was lying around at the end of training. But you can't tell without looking inside, and most people look inside by loading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newcomers brought them
&lt;/h2&gt;

&lt;p&gt;Between July 2024 and July 2025, about 1,100 people a month uploaded a pickle-only model. From March to September this year it was 3,300 a month, while everyone uploading weights went from about 14,000 to 22,000. Only about one in twelve of this year's pickle uploaders had uploaded any weights in the first seven months of 2025.&lt;/p&gt;

&lt;p&gt;Four in five of their repos don't say what library they're for. Looking through them, it's YOLO detectors, crop disease classifiers, robot policies, RVC voice models, research checkpoints and a lot of coursework. &lt;code&gt;torch.save(model, "best.pt")&lt;/code&gt; and push.&lt;/p&gt;

&lt;p&gt;They don't seem to get many downloads, though that's hard to pin down. The Hub only counts a download for a repo with no library when someone fetches its config file, and most of these haven't got one. Where it can count them, the pickle-only repos made between March and September got about 0.2% of the last 30 days' downloads of everything new from those months. This is the long tail, the stuff you land on from a search, a paper or a tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's slipped since May
&lt;/h2&gt;

&lt;p&gt;May was 15.1%, then 14.0, 13.0, 12.5 and 12.3 in September. The first two weeks of October are about 8%, and that's not because the month's half finished, since the same days read the same in a snapshot from five days earlier.&lt;/p&gt;

&lt;p&gt;Some of October is a wave of new people. Nearly 2,000 more a week than September's average uploaded weights, most of it safetensors, under names like &lt;code&gt;tiny-transformer&lt;/code&gt; and &lt;code&gt;cnn-transformer&lt;/code&gt; that look a lot like a new term. But pickle-only uploaders fell too, from about 850 a week in September to about 730, and last October the share went up rather than down. I'm not sure what's going on there.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>huggingface</category>
      <category>pytorch</category>
    </item>
    <item>
      <title>One domain seller is 6% of the HSTS preload list</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:54:58 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/one-domain-seller-is-6-of-the-hsts-preload-list-fmg</link>
      <guid>https://dev.to/notaflightrisk/one-domain-seller-is-6-of-the-hsts-preload-list-fmg</guid>
      <description>&lt;p&gt;The &lt;a href="https://hstspreload.org/" rel="noopener noreferrer"&gt;HSTS preload list&lt;/a&gt; is the list of sites your browser will only ever load over HTTPS, before it's even visited them once. It's built into Chrome and Edge, and Firefox makes its own copy from it. You get on by serving HTTPS on every subdomain, sending one header and filling in a form, and the idea is that it's for sites that care about HTTPS enough to promise it for good.&lt;/p&gt;

&lt;p&gt;I pulled &lt;a href="https://github.com/chromium/chromium/blob/main/net/http/transport_security_state_static.json" rel="noopener noreferrer"&gt;Chromium's copy&lt;/a&gt; on 17 October and looked up all 95,147 entries, mostly out of nosiness about who's on it. The two addresses with the most names on them both belong to someone selling domains.&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%2F0gd61f269qep6fciv5hy.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%2F0gd61f269qep6fciv5hy.png" alt="Who's on the HSTS preload list" width="799" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who's on the HSTS preload list&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/one-seller-hsts-preload-list/addresses.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Chromium's entries by the first IPv4 address they resolve to, 17 October 2026. Below the top two, every row is a host with lots of customers or one company's own sites. Cloudflare's behind far more than any of these, but spread over lots of addresses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's 5,691 entries between them, about one in every seventeen on the list. Pick one at random and you'll get a white page with the name in capitals, like "HAOMIN.COM MAY BE AVAILABLE!", a button to buy it on GoDaddy and "Proudly Powered by GKJ.com" at the bottom. &lt;a href="https://gkj.com/" rel="noopener noreferrer"&gt;GKJ's own site&lt;/a&gt; says "You came looking for a domain. We probably already own it", and puts the count at 5,000+. Who's registered them is redacted, so I'm going on their word that it's one seller.&lt;/p&gt;

&lt;p&gt;I fetched them slowly in random order until the server started telling me to go away. 498 of the 511 that answered were that sale page, 7 more said "UNDER CONSTRUCTION" in the same style, and all 511 sent &lt;code&gt;max-age=63072000; includeSubDomains; preload&lt;/code&gt;. There's nothing against the rules in that. When I ran 20 of them through &lt;a href="https://github.com/chromium/hstspreload" rel="noopener noreferrer"&gt;Chromium's own checker&lt;/a&gt; this afternoon, all 20 said "Satisfies requirements".&lt;/p&gt;

&lt;p&gt;Why a domain seller bothers, I don't know. The few pages the Wayback Machine kept from 2022 to 2024 come from the same kit, with the name in capitals over a slogan like "IN GOD WE TRUST", and the 2022 ones already sent the header. So my guess is it just comes with whatever makes the pages.&lt;/p&gt;

&lt;p&gt;It does matter to whoever buys one, though. They get a domain that browsers will only load over HTTPS, and every subdomain under it too, so &lt;code&gt;nas.theirname.com&lt;/code&gt; with a self-signed certificate on the home network won't load, because HSTS takes away the button that lets you click through the warning. Getting off the list means asking for removal and then waiting, which hstspreload.org reckons "may take 6-12 weeks to reach most Chrome users, and may take longer for other browsers". You can check any name first with &lt;code&gt;curl "https://hstspreload.org/api/v2/status?domain=haomin.com"&lt;/code&gt;, which says &lt;code&gt;"status": "preloaded"&lt;/code&gt; for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chromium cut them twice
&lt;/h2&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%2Fmehe55fggyovws72xktr.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%2Fmehe55fggyovws72xktr.png" alt="This seller's names on Chromium's list" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This seller's names on Chromium's list&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/one-seller-hsts-preload-list/chromium.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How many of today's 5,691 names were on the list at the end of each month, January 2022 to October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nine in ten of them went on in one &lt;a href="https://github.com/chromium/chromium/commit/6886f98ba4709980fdb9b418a6c7db6aa7c6c7b5" rel="noopener noreferrer"&gt;bulk update in June 2022&lt;/a&gt;. In April 2023 Chromium &lt;a href="https://chromium-review.googlesource.com/c/chromium/src/+/4455622" rel="noopener noreferrer"&gt;pruned&lt;/a&gt; every domain that had "repeatedly failed to meet the continued requirements", which took out 41% of the list and 91% of this seller's names. They went back through the same form as everyone else, and most were back on within six months.&lt;/p&gt;

&lt;p&gt;Then a &lt;a href="https://chromium-review.googlesource.com/c/chromium/src/+/6897476" rel="noopener noreferrer"&gt;bulk update in August 2025&lt;/a&gt; did it again, cutting the list from 171,166 entries to 95,329 and the seller from over 5,500 to 405. Of 40 other names it removed, every one still fails today, mostly because nothing answers, so it wasn't a bad clear-out. It just took these with it, and 4,659 of the names on the list today have now been cut twice.&lt;/p&gt;

&lt;p&gt;They were back within months again, and because that update cleared out so much else, this seller's now pretty much all of the list's growth. Since August 2025 the whole list has shrunk by 182 entries, while this one block grew by 5,286.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox kept losing them
&lt;/h2&gt;

&lt;p&gt;Firefox doesn't take Chromium's list as it is. &lt;a href="https://github.com/mozilla-firefox/firefox/blob/main/taskcluster/docker/periodic-updates/scripts/getHSTSPreloadList.js" rel="noopener noreferrer"&gt;A script&lt;/a&gt; visits every site on it about once a week and drops the ones that don't send the header.&lt;/p&gt;

&lt;p&gt;Visiting thousands of sites on one server all at once is exactly what this server doesn't like. When I fetched all 5,691 at 64 at a time, nearly every answer was a 429 Too Many Requests, and not one of those had the header. So in Firefox they came and went depending on how the week's run went:&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%2Ftjov2k7aca3r9x5mcl7j.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%2Ftjov2k7aca3r9x5mcl7j.png" alt="This seller's names on Firefox's list" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This seller's names on Firefox's list&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/one-seller-hsts-preload-list/firefox.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each update to Firefox's preload list since June 2026. Mozilla's fix went in on 5 October, between the 2 and 6 October updates.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ryan VanderMeulen at Mozilla spotted it last month. In &lt;a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2075853" rel="noopener noreferrer"&gt;bug 2075853&lt;/a&gt; he wrote that Chromium's list "holds thousands of domains of one hosting operator in a contiguous block, all served from two addresses", and that checking them in list order meant "the operator answered with 429 for thousands of hosts that otherwise send a valid header, which kept them off the preload list".&lt;/p&gt;

&lt;p&gt;The fix landed on 5 October. The script now shuffles the list, tries 429s again at the end, and an error page without the header doesn't knock a site off anymore. Before that, a run where more of them got through, like 26 September's, was gone again the week after. Since then every update has kept what it had and added more, up to 2,275 on 16 October. That's still well short of 5,691, and the bug says why: "the operator still limits the rate over the run as a whole".&lt;/p&gt;

&lt;h2&gt;
  
  
  Chromium's checker gets waved through
&lt;/h2&gt;

&lt;p&gt;I'd assumed Chromium's culls tripped over the same 429s. Its &lt;a href="https://github.com/chromium/hstspreload.org/blob/main/api/domain_handlers.go" rel="noopener noreferrer"&gt;removal job&lt;/a&gt; checks every bulk entry weekly, only looks at the header and not the status code, and queues a domain for removal once it's failed for a month.&lt;/p&gt;

&lt;p&gt;But Chromium's checker calls itself &lt;code&gt;hstspreload-bot&lt;/code&gt;, so this evening I got the server annoyed with 300 quick requests, then asked ten more names for their page under four different User-Agents:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User-Agent&lt;/th&gt;
&lt;th&gt;What came back&lt;/th&gt;
&lt;th&gt;HSTS header&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hstspreload-bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same for all ten names. So the rate limit lets Chromium's checker straight through, and it's everyone else who gets the 429, Firefox included. What it did in 2023 and 2025 I can't tell, and the Wayback Machine won't show me enough from just before either cull to find out. They pass Chromium's checks now, and they were cut anyway.&lt;/p&gt;

</description>
      <category>security</category>
      <category>web</category>
      <category>hsts</category>
      <category>browsers</category>
    </item>
    <item>
      <title>Most sites that block curl only check its name</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:54:27 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/most-sites-that-block-curl-only-check-its-name-1f5j</link>
      <guid>https://dev.to/notaflightrisk/most-sites-that-block-curl-only-check-its-name-1f5j</guid>
      <description>&lt;p&gt;Here's chess.com this morning, with the same curl asking five times under different names:&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;$ &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;ua &lt;span class="k"&gt;in &lt;/span&gt;curl/8.18.0 Curl/8.18.0 python-requests/2.32.5 Mozilla/5.0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'myfetcher/1.0 (+https://example.org/bot)'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}  &lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://chess.com/
  &lt;span class="k"&gt;done
&lt;/span&gt;403  curl/8.18.0
200  Curl/8.18.0
403  python-requests/2.32.5
200  Mozilla/5.0
200  myfetcher/1.0 &lt;span class="o"&gt;(&lt;/span&gt;+https://example.org/bot&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A capital C gets you in. That one's chess.com being a bit lazy, and most sites aren't that easy, but the handshake is identical evey time, so the only thing it can be looking at is the name.&lt;/p&gt;

&lt;p&gt;That's not what you'll usually hear. Ask why curl gets a 403 and the answer is TLS fingerprinting: big sites look at how your client shakes hands, curl looks nothing like Chrome, so changing the User-Agent won't get you far and you want &lt;a href="https://github.com/lexiforest/curl-impersonate" rel="noopener noreferrer"&gt;curl-impersonate&lt;/a&gt; or a headless browser. Even &lt;a href="https://daniel.haxx.se/blog/2022/09/02/curls-tls-fingerprint/" rel="noopener noreferrer"&gt;Daniel Stenberg&lt;/a&gt;, who started curl, calls the User-Agent check "the original and much simpler way" of spotting bots.&lt;/p&gt;

&lt;p&gt;That's true for some sites. I wanted to know how many, so I went through the &lt;a href="https://tranco-list.eu/" rel="noopener noreferrer"&gt;Tranco&lt;/a&gt; top 10,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two in three
&lt;/h2&gt;

&lt;p&gt;I sent four clients at every homepage on 18 October, from my server: stock curl 8.18, the same curl with a Chrome 141 User-Agent and Chrome's Accept headers, the same curl calling itself &lt;code&gt;myfetcher/1.0&lt;/code&gt;, and &lt;a href="https://github.com/lexiforest/curl_cffi" rel="noopener noreferrer"&gt;curl_cffi&lt;/a&gt; pretending to be Chrome right down to the TLS handshake.&lt;/p&gt;

&lt;p&gt;The fake Chrome got a proper page from 5,874 of the 10,000. Of the rest, 1,508 blocked even that, which I'd guess is mostly about my server being in a datacentre, and most of the others just didn't answer. So read everything below as "sites that'll talk to a datacentre at all". The strictest ones aren't in it.&lt;/p&gt;

&lt;p&gt;Stock curl got blocked at 703 of those 5,874, nearly all with a 403, and about two in five of those a Cloudflare challenge page. Give it Chrome's User-Agent and 451 of the 703 hand over the full page, which is 64%. The made-up &lt;code&gt;myfetcher&lt;/code&gt; gets into 421, so 60%. stanford.edu, un.org, cnbc.com, zendesk.com and flightradar24.com are all in there.&lt;/p&gt;

&lt;p&gt;It's not a fluke of the day either. Of 453 sites that only blocked the name in runs on 17 October, 441 still did today. A sample of inner pages and page titles from those runs matched what Chrome got, so these aren't stripped-down pages being waved through.&lt;/p&gt;

&lt;p&gt;The closest count I could find is &lt;a href="https://arxiv.org/html/2606.14525" rel="noopener noreferrer"&gt;a paper on headless Chrome&lt;/a&gt;, where swapping &lt;code&gt;HeadlessChrome&lt;/code&gt; for &lt;code&gt;Chrome&lt;/code&gt; cleared 75% of the blocks that only hit headless Chrome. That's a real browser already, though, and I couldnt find anyone who'd tried it with curl.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which names get in?
&lt;/h2&gt;

&lt;p&gt;I took the 451 sites that let the Chrome User-Agent through and tried ten User-Agents on each, going by the status code this time:&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%2Fmmjqobj4ciaevsezzo5u.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%2Fmmjqobj4ciaevsezzo5u.png" alt="Sites that let each User-Agent in" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sites that let each User-Agent in&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/sites-that-block-curl/names.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Share of the 451 sites that block curl but let a Chrome User-Agent in, 18 October&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;python-requests, Wget and Go's default client get turned away almost as often as curl, and so does sending no User-Agent at all. 96 of the sites seem to block "curl" anywhere in the string, as &lt;code&gt;myfetcher/1.0 (curl)&lt;/code&gt; gets turned away where the honest one doesn't. Only 55 are like chess.com and miss &lt;code&gt;Curl&lt;/code&gt; with a capital. The 2% for plain curl is seven sites that had started letting it in by then.&lt;/p&gt;

&lt;p&gt;I didn't expect &lt;code&gt;Mozilla/5.0&lt;/code&gt; on its own to do so badly. It's what &lt;a href="https://www.scivision.dev/curl-user-agent/" rel="noopener noreferrer"&gt;alot of advice&lt;/a&gt; tells you to use, and it only gets into a third of them. Something that reads like a polite bot gets into 85% of these 451, and Miniflux's feed reader string 88%. Feed readers seem fine generally, for what it's worth: of 153 feeds advertised on the top 2,000 homepages, 7 turned Miniflux away.&lt;/p&gt;

&lt;p&gt;So if your script gets a 403, give it a name first. Anything that isn't a tool's default will do, and a URL in it is polite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'myfetcher/1.0 (+https://example.org/bot)'&lt;/span&gt; https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does it matter who's in front?
&lt;/h2&gt;

&lt;p&gt;Quite a lot. I read the headers on each block to see whose edge sent it. That tells you who's in front of the site, not who wrote the rule, since a Cloudflare customer can block curl in thier own firewall rules without Cloudflare having any say. Here's the share of curl blocks that the Chrome User-Agent and &lt;code&gt;myfetcher&lt;/code&gt; each got past (AWS is CloudFront and friends):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In front&lt;/th&gt;
&lt;th&gt;Blocks&lt;/th&gt;
&lt;th&gt;Chrome&lt;/th&gt;
&lt;th&gt;myfetcher&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;392&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Akamai&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;28%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;51%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Behind Cloudflare it's mostly a name check, and any other name will do. Behind Akamai it's mostly the fingerprinting people warn you about, and three in four still won't give curl a page with a Chrome User-Agent on. Behind AWS it wants something that looks like a browser: Mercado Libre's sites, realtor.com and osha.gov let Chrome's string in and turn &lt;code&gt;myfetcher&lt;/code&gt; away.&lt;/p&gt;

&lt;p&gt;Akamai's also why the middle of the list does worse. Of the sites ranked 2,001 to 5,000 that block curl, only 46% let the Chrome string in, because Akamai's in front of 50 of those 169. Further down, from 5,001 to 10,000, it's in front of 55 of 401, and the Chrome string gets into 71%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about from somewhere else?
&lt;/h2&gt;

&lt;p&gt;All of that is from one server in a datacentre. Last night I ran the 157 sites that blocked anything in the top 2,000 again through Cloudflare's WARP VPN, and through Tor. The Chrome User-Agent did a bit better on WARP (81% of curl blocks) and the same on Tor (72%) as from my server. About half the Akamai sites that blocked even the Chrome string from my server only checked the name on WARP, so the address counts as well as the name. I haven't tried it from home broadband, so that's the bit I can't tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fake Chrome by halves
&lt;/h2&gt;

&lt;p&gt;There's a catch with the Chrome string. 79 sites served stock curl fine, then blocked the same curl as soon as it said it was Chrome. adobe.com, slack.com, cisco.com, intel.com, cdc.gov and opensource.org are on that list, and so is w3.org, so the W3C, who write a good chunk of the web's standards, will serve curl all day, just not curl claiming to be Chrome. 32 of the 79 are behind Akamai. My guess is the rule there is "says Chrome, doesn't shake hands like Chrome", which is exactly what curl with a Chrome User-Agent is.&lt;/p&gt;

&lt;p&gt;61 of them still let &lt;code&gt;myfetcher&lt;/code&gt; in. And it's partly the address again. 12 sites in the top 2,000 did this to my server. Over Tor, all seven I could reach still did, but over WARP only 3 of the 12 did.&lt;/p&gt;

&lt;p&gt;So try the honest name first, and save the Chrome string for when that fails, or for anything behind AWS.&lt;/p&gt;

</description>
      <category>security</category>
      <category>web</category>
      <category>curl</category>
      <category>scraping</category>
    </item>
    <item>
      <title>Skip the system prompt and the template writes one</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:53:56 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/skip-the-system-prompt-and-the-template-writes-one-4253</link>
      <guid>https://dev.to/notaflightrisk/skip-the-system-prompt-and-the-template-writes-one-4253</guid>
      <description>&lt;p&gt;I'd always assumed that if I leave the system prompt out, the model gets my message plus a few role markers round it, and that's it. That's what a &lt;a href="https://huggingface.co/docs/transformers/chat_templating" rel="noopener noreferrer"&gt;chat template&lt;/a&gt; is for: the bit of Jinja that ships with a model and turns your list of messages into the one long string it actually reads.&lt;/p&gt;

&lt;p&gt;So I sent Llama 3.1 8B Instruct a lone "Who made you?" through transformers, and printed the string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;|begin_of_text|&amp;gt;&amp;lt;|start_header_id|&amp;gt;system&amp;lt;|end_header_id|&amp;gt;

Cutting Knowledge Date: December 2023
Today Date: 26 Jul 2024

&amp;lt;|eot_id|&amp;gt;&amp;lt;|start_header_id|&amp;gt;user&amp;lt;|end_header_id|&amp;gt;

Who made you?&amp;lt;|eot_id|&amp;gt;&amp;lt;|start_header_id|&amp;gt;assistant&amp;lt;|end_header_id|&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a system block that I didn't write, telling the model that it's July 2024. I checkec my watch - todays the 19th of October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over a quarter of them add something
&lt;/h2&gt;

&lt;p&gt;I took the 300 most downloaded text generation models on Hugging Face today and rendered a single user message through each one's template, using transformers' own renderer. 189 have a template in the repo (most of the rest are GGUF files, which keep theirs inside, or base models). Then I stripped out the role markers, the special tokens and my message, and looked at what was left.&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%2Fmosgik6eq5i9kzxhuzce.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%2Fmosgik6eq5i9kzxhuzce.png" alt="What 189 popular chat templates add to a lone user message" width="800" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What 189 popular chat templates add to a lone user message&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/chat-templates-write-a-system-prompt/adds.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The top 300 text generation models on Hugging Face by downloads, 19 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;114 add nothing, which is what I'd expected from all of them. 23 add something bland, mostly "You are a helpful assistant" or a line setting how hard to think. But the other 52 tell the model who it is, who made it, when its training stopped, what today's date is, or what it's allowed to talk about.&lt;/p&gt;

&lt;p&gt;28 of those 52 are one line, Qwen2.5's, repeated across its sizes and everybody's quantised copies of it (Qwen3 dropped it). Lots of the rest are copies too, and if you count each of the 96 different templates once, it's 20 of them that add a name, a date or a scope. So more like a fifth, but those are some of the biggest names on there:&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 adds when you don't&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5 7B Instruct&lt;/td&gt;
&lt;td&gt;You are Qwen, created by Alibaba Cloud. You are a helpful assistant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss 20B&lt;/td&gt;
&lt;td&gt;You are ChatGPT, a large language model trained by OpenAI. Knowledge cutoff: 2024-06 Current date: 2026-10-19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 3.3 2B&lt;/td&gt;
&lt;td&gt;Knowledge Cutoff Date: April 2024. Today's Date: October 19, 2026. You are Granite, developed by IBM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K2 Instruct&lt;/td&gt;
&lt;td&gt;You are Kimi, an AI assistant created by Moonshot AI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek Coder 7B v1.5&lt;/td&gt;
&lt;td&gt;You only answer questions related to computer science. For politically sensitive questions, security and privacy issues... you will refuse to answer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So OpenAI's open model calls itself ChatGPT, and a coding model is told not to talk about security. None of it's hidden, it's all in the repo, but you'd only know if you went and read the template.&lt;/p&gt;

&lt;h2&gt;
  
  
  July 2024 is written into Llama's
&lt;/h2&gt;

&lt;p&gt;Most templates that mention a date ask for today's with &lt;code&gt;strftime_now&lt;/code&gt;, a function whatever's running the model is meant to supply. &lt;a href="https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct" rel="noopener noreferrer"&gt;Meta's Llama 3.1&lt;/a&gt; and 3.3 templates do this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nv"&gt;date_string&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;date_string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"26 Jul 2024"&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's three days after 3.1 came out. If the runner passes a date the model gets that, and if it passes nothing it's 26 July 2024 forever. That's the official Llama 3.1 8B Instruct, downloaded 5.5 million times in the last month, and the quantised copies I checked carry the same file. Llama 3.2 switched to &lt;code&gt;strftime_now&lt;/code&gt;, so it gets today.&lt;/p&gt;

&lt;p&gt;So does anything pass a date? Same bare message, Meta's 3.1 template, today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run with&lt;/th&gt;
&lt;th&gt;Told the date is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;transformers&lt;/td&gt;
&lt;td&gt;26 Jul 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM 0.32&lt;/td&gt;
&lt;td&gt;26 Jul 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp's llama-server&lt;/td&gt;
&lt;td&gt;19 Oct 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama 0.40, &lt;code&gt;ollama pull llama3.1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing, no system block at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama 0.40, a GGUF you imported&lt;/td&gt;
&lt;td&gt;19 Oct 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(No GPU here, so each runner got a tiny test model wearing the real template, and for vLLM that's its template renderer called directly.) llama.cpp &lt;a href="https://github.com/ggml-org/llama.cpp/blob/f174553e1621c6a22a64e7bfcbb58d3a5c49e6fb/common/chat.cpp#L1065-L1072" rel="noopener noreferrer"&gt;sets date_string to today&lt;/a&gt; on every render, and vLLM doesn't set it anywhere. Ollama's own llama3.1 isn't Meta's template at all but a Go one, which only opens a system block when you send a system prompt or tools. Import a GGUF that carries Meta's Jinja, though, and Ollama hands it to its bundled llama-server, which puts today in.&lt;/p&gt;

&lt;p&gt;Qwen2.5's line came through transformers and llama-server the same, and &lt;a href="https://ollama.com/library/qwen2.5" rel="noopener noreferrer"&gt;Ollama's qwen2.5&lt;/a&gt; ships it as the model's default system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your own system prompt only fixes half of it
&lt;/h2&gt;

&lt;p&gt;For Qwen2.5, passing any system message switches the default off, even an empty one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;|im_start|&amp;gt;system
&amp;lt;|im_end|&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Llama 3.1 is different. Send "Be brief." as the system prompt and the template keeps the stale date and puts yours underneath it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cutting Knowledge Date: December 2023
Today Date: 26 Jul 2024

Be brief.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unless your runner fills it in like llama-server does, you have to pass &lt;code&gt;date_string&lt;/code&gt; yourself. In transformers it's another argument to &lt;code&gt;apply_chat_template&lt;/code&gt;. vLLM takes it in &lt;code&gt;chat_template_kwargs&lt;/code&gt; on the request, which it &lt;a href="https://github.com/vllm-project/vllm/blob/v0.32.0/vllm/entrypoints/openai/chat_completion/protocol.py#L366-L372" rel="noopener noreferrer"&gt;hands to the template&lt;/a&gt;:&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"chat_template_kwargs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_string"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"19 Oct 2026"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The model mostly knew already
&lt;/h2&gt;

&lt;p&gt;I wanted to see the DeepSeek Coder refusal for real, so I ran the 1.3B version (same line in its template) with the default and with an empty system message. It happily answered questions on storing passwords in Flask, SQL injection and setting up ufw either way, in nearly the same words. It turned down "What is the capital of France?" and a question about Tiananmen Square either way too, and without its line it said "as an AI focused on history, I don't have the ability to provide information on historical events". Qwen2.5 0.5B says it was made by Alibaba Cloud with or without its line.&lt;/p&gt;

&lt;p&gt;So the names and the scopes are mostly belt and braces. The model learned them in training, and the template just repeats them again.&lt;/p&gt;

&lt;p&gt;The date's different though, since no model can know what day it is. I gave Llama 3.2 3B six questions that hang on the date, under the block Meta's 3.1 template writes, the same block with today's date, and no block. Most answers ignored the date whichever way, and it worked out someone's age "as of 2023" all three times. The one that moved was a one-line email greeting mentioning the season. July 2024 got "the warm and sunny summer season", today got "the crisp autumn season", and no date got a helpful "[current season]".&lt;/p&gt;

&lt;p&gt;That's a small model and one answer in six, but it's the one line in these templates that's news to the model, and it's the one the runners can't agree on.&lt;/p&gt;

&lt;h2&gt;
  
  
  See what yours sends
&lt;/h2&gt;

&lt;p&gt;In transformers, print the template's output before it's tokenised:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen/Qwen2.5-0.5B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And llama-server will show you what it's about to send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:8080/apply-template &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"messages": [{"role": "user", "content": "hi"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So... if there's a date in in your response, check it's actually today's.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>llm</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>37 of the top 50 self-hosted apps can go closed</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:53:25 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/37-of-the-top-50-self-hosted-apps-can-go-closed-16jc</link>
      <guid>https://dev.to/notaflightrisk/37-of-the-top-50-self-hosted-apps-can-go-closed-16jc</guid>
      <description>&lt;p&gt;Pick something you self-host. Could the people behind it put the next release out under terms that aren't open source, without asking anyone?&lt;/p&gt;

&lt;p&gt;I went through the 50 most-starred apps on &lt;a href="https://awesome-selfhosted.net" rel="noopener noreferrer"&gt;awesome-selfhosted&lt;/a&gt;, its non-free list included, to find out. For 37 of them the answer is yes, or they already have. The AGPL badge on the repo doesn't tell you which group yours is in.&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%2F1psy7c2p0buqkc3a8hcb.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%2F1psy7c2p0buqkc3a8hcb.png" alt="The 50 most-starred self-hosted apps" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 50 most-starred self-hosted apps&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/self-hosted-apps-can-go-closed/groups.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A square for each app&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Apps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Already ship some non-open code (11)&lt;/td&gt;
&lt;td&gt;Dify, Open WebUI, Stirling-PDF, LobeHub, NocoDB, Meilisearch, Twenty, Metabase, Rocket.Chat, Outline, Novu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissive (18)&lt;/td&gt;
&lt;td&gt;Ollama, Hoppscotch, code-server, AnythingLLM, Caddy, Traefik, Memos, PocketBase, Gitea, Appwrite, LocalAI, LibreChat, Hyperswitch, Reactive Resume, Kong, Appsmith, MindsDB, Umami&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copyleft with a company CLA (8)&lt;/td&gt;
&lt;td&gt;AppFlowy, Plane, Odoo, Discourse, Puter, ToolJet, PhotoPrism, Halo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copyleft, no CLA (11)&lt;/td&gt;
&lt;td&gt;Immich, Syncthing, Vaultwarden, Pi-hole, Penpot, Jellyfin, SiYuan, paperless-ngx, RSSHub, Sunshine, ERPNext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissive, owned by a foundation (2)&lt;/td&gt;
&lt;td&gt;Home Assistant, Apache Airflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three groups are the 37, and the 11 at the top are a mix. NocoDB, Outline, Dify, LobeHub and Open WebUI put the whole app under their own terms, while Metabase, Rocket.Chat, Twenty, Meilisearch, Novu and Stirling-PDF fence off the paid bits in a folder or marked files. Anyone could make a closed fork of Home Assistant or Airflow, but the official ones belong to foundations that exist to keep them open.&lt;/p&gt;

&lt;p&gt;I only counted what's in each public repo as of 17 October, so Odoo and Kong, which already sell closed enterprise editions from elsewhere, aren't in the first group. I ran the next 50 as well and it drops to 31, beacuse there are more volunteer projects like SearXNG and Invidious with no company to do it. That's 68 of the top 100.&lt;/p&gt;

&lt;h2&gt;
  
  
  NocoDB did it in one commit
&lt;/h2&gt;

&lt;p&gt;NocoDB is a self-hosted Airtable with 65,000 stars, and untill this year it was AGPL. On 8 January a commit called &lt;a href="https://github.com/nocodb/nocodb/commit/d98ad39c9b147be0fbd4e1e6ffbf354283b0b4e1" rel="noopener noreferrer"&gt;"chore: change to sustainable use license"&lt;/a&gt; deleted the 661-line AGPL and put 61 lines of the Sustainable Use License in it's place, the same licence n8n uses. The last AGPL release, 0.265.1, came out last October. Every release since says this instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You may use or modify the software only for your own internal business purposes or for non-commercial or personal use.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub lists 336 contributors to that repo. None of them had to be asked, and a week after the release a user &lt;a href="https://github.com/nocodb/nocodb/discussions/12891" rel="noopener noreferrer"&gt;said as much&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For the record, this project uses a CLA, so there isn't a legal issue with changing the license of external contributions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A CLA, or contributor licence agreement, is something you sign before your PR gets merged, and NocoDB's contributing guide has asked for one since 2022. The founder's reason for the switch, &lt;a href="https://forum.cloudron.io/topic/14918/heads-up-nocodb-is-no-longer-open-source." rel="noopener noreferrer"&gt;posted on Cloudron's forum&lt;/a&gt;, was companies running private forks and ignoring the AGPL, with lawyers too pricey to chase them. I've a bit of sympathy for that. But it was NocoDB's call alone, and the &lt;a href="https://nocodb.com/docs/self-hosting/license" rel="noopener noreferrer"&gt;licence page&lt;/a&gt; says "The license protects contributors", which is one way of putting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CLA's the bit that matters
&lt;/h2&gt;

&lt;p&gt;A copyleft licence is meant to stop exactly this. If I send a fix to an AGPL project, my fix is AGPL, and nobody can ship it closed without my say-so. A CLA is where I give that say-so up front.&lt;/p&gt;

&lt;p&gt;AppFlowy, Plane, Puter, ToolJet and PhotoPrism are all AGPL today, and all have a CLA. So do Odoo, Discourse and Halo on the other GPLs. I read all eight agreements and every one lets the company license your code on other terms. PhotoPrism's &lt;a href="https://web.photoprism.app/cla/" rel="noopener noreferrer"&gt;is the bluntest&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;this includes the right to apply any license to the Contributions that PhotoPrism considers fit for the respective purpose as decided by PhotoPrism in its sole discretion, including other open-source, copyleft, and proprietary licenses&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of that is hidden, and people have been asking about it for ages. Discourse's own forum has a &lt;a href="https://meta.discourse.org/t/1399" rel="noopener noreferrer"&gt;thread from 2013&lt;/a&gt; titled "Does Discourse's contributor agreement provide an exit door from Open Source?". Discourse is still GPL 13 years on.&lt;/p&gt;

&lt;p&gt;Companies add the CLA early, though. The copyleft apps that did change all had theirs well before they used it. Metabase's came more than four years ahead of its &lt;code&gt;enterprise/&lt;/code&gt; folder, and NocoDB's nearly four years ahead of January.&lt;/p&gt;

&lt;p&gt;Not every CLA lets them do it, mind. Postiz, 61st by stars, uses the FSFE's &lt;a href="https://github.com/gitroomhq/postiz-app/blob/main/ICLA.md" rel="noopener noreferrer"&gt;Fiduciary License Agreement&lt;/a&gt;, which says any closed version of your code has to come with an open one too.&lt;/p&gt;

&lt;p&gt;My own box isn't any better. My analytics run on &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;Plausible&lt;/a&gt;, which is AGPL with a CLA bot on every PR, and has an &lt;code&gt;extra/&lt;/code&gt; folder whose licence grants "no rights to use, distribute or otherwise exploit this software". The community edition I run doesn't compile it in. My error tracker, &lt;a href="https://github.com/bugsink/bugsink" rel="noopener noreferrer"&gt;Bugsink&lt;/a&gt;, was never open source to begin with, and I picked it anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  MIT never needed one
&lt;/h2&gt;

&lt;p&gt;The latest app to switch isn't even in the top 50. ThingsBoard, 100th by stars, swapped Apache 2.0 for the Business Source License in &lt;a href="https://github.com/thingsboard/thingsboard/releases/tag/v4.4" rel="noopener noreferrer"&gt;4.4&lt;/a&gt;, out on 29 September. You can still use it for free with a licence key from them, but only up to 1,000 devices, or 100 on a single server if you're a business. The release notes don't mention it, and awesome-selfhosted still lists it as Apache.&lt;/p&gt;

&lt;p&gt;It didn't have to ask anyone. MIT and Apache let anyone take the code and ship a closed version, as long as they keep the licence and notices, and that includes the people who wrote it. Contributors' code comes in under the same terms, so there's nothing to sign. That's how most of the 11 got there. The 18 permissive apps are the biggest group in the table, and the one people worry abut least.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stirling-PDF didn't need a CLA either
&lt;/h2&gt;

&lt;p&gt;Copyleft with no CLA should be the safe group. Contributors licensed their code as GPL or AGPL and nothing else, so the owners can't take it closed without evey one of them agreeing. Some projects ask for a &lt;code&gt;Signed-off-by&lt;/code&gt; line on each commit (a DCO), but that hands over nothing.&lt;/p&gt;

&lt;p&gt;Stirling-PDF was in that group until September 2024, when &lt;a href="https://github.com/Stirling-Tools/Stirling-PDF/commit/da6b66e1" rel="noopener noreferrer"&gt;a single commit&lt;/a&gt; swapped its GPL-3.0 for MIT. An &lt;code&gt;app/proprietary/&lt;/code&gt; folder came the next year. I couldn't find a PR or anything asking past contributors, which doesn't mean nobody did.&lt;/p&gt;

&lt;p&gt;The rest aren't quite as locked as they look. Immich went from MIT to AGPL in February 2024 and RSSHub only last December, so anyone can still take their MIT-era code closed. Sunshine's owner had contributors assign it their copyright from 2022 until it dropped its CLA in 2024, so it owns what people sent in during that time outright. And Syncthing and Penpot are MPL 2.0, which works file by file, so a brand new file can be proprietary.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run one of these
&lt;/h2&gt;

&lt;p&gt;Whatever's allready been released stays under the licence it shipped with. NocoDB's 0.265.1 is AGPL forever and anyone can fork it, though I couldn't find anyone keeping up an AGPL fork.&lt;/p&gt;

&lt;p&gt;If you run one at home, the new licences still let you. New features just go in the paid bit. Twenty's SSO arrived in 2024 as commercial-only files, and an &lt;a href="https://github.com/nocodb/nocodb/issues/14357" rel="noopener noreferrer"&gt;issue from August&lt;/a&gt; points out NocoDB's new documents API needs an Enterprise licence on self-hosted.&lt;/p&gt;

&lt;p&gt;To check an app that isn't in the table, look for "CLA" in its contributing guide, then at the checks on a merged PR from an outside contributor:&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;$ &lt;/span&gt;gh &lt;span class="nb"&gt;pr &lt;/span&gt;checks 10037 &lt;span class="nt"&gt;-R&lt;/span&gt; makeplane/plane | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cla
license/cla  pass  0  https://cla-assistant.io/makeplane/plane?pullRequest&lt;span class="o"&gt;=&lt;/span&gt;10037  Contributor License Agreement is signed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That won't catch a CLA that's only a file in the repo, like Open WebUI's.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosting</category>
      <category>licensing</category>
    </item>
    <item>
      <title>Most big Firefox add-ons still don't say what they collect</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:52:54 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/most-big-firefox-add-ons-still-dont-say-what-they-collect-2a3a</link>
      <guid>https://dev.to/notaflightrisk/most-big-firefox-add-ons-still-dont-say-what-they-collect-2a3a</guid>
      <description>&lt;p&gt;Here's what Firefox showed me this morning when I went to install two password managers:&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%2Fxxr1bs4h90zkzuq0q5s3.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%2Fxxr1bs4h90zkzuq0q5s3.png" alt="Firefox's prompt to add Bitwarden Password Manager. It lists six required permissions, like accessing your data for all websites and the clipboard, then an optional setting for private windows. There's no data collection section." width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Bitwarden 2026.10.0, out on 15 October&lt;/em&gt;&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%2F1umm1w0m04b3r8uz2mvw.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%2F1umm1w0m04b3r8uz2mvw.png" alt="Firefox's prompt to add LastPass. It lists five required permissions, then a Required data collection section saying the developer says it collects browsing activity, website activity, authentication information and location, then a pre-ticked box to share technical and interaction data." width="800" height="723"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LastPass 4.154.2&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Which one's telling you more? LastPass, obviously. It's got a whole "Required data collection" section, and a pre-ticked box for sending the developer technical data. Bitwarden's just stops after the permissions, and it'd be easy to read that as "collects nothing".&lt;/p&gt;

&lt;p&gt;But, that's not what this actually means.. An add-on that really collects nothing will say so in the prompt - Proton Pass's says "The developer says this extension doesn't require data collection." Bitwarden's has no data section at all, since nobody's made Bitwarden fill it in.&lt;/p&gt;

&lt;p&gt;Since &lt;a href="https://blog.mozilla.org/addons/2025/10/23/data-collection-consent-changes-for-new-firefox-extensions/" rel="noopener noreferrer"&gt;3 November 2025&lt;/a&gt;, every new add-on on addons.mozilla.org (AMO) has had to put a &lt;code&gt;data_collection_permissions&lt;/code&gt; key in its manifest, even if it's only to say "none", and Firefox 140+ shows it in that prompt. Add-ons that already existed were let off, but Mozilla said that wouldn't last:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the first half of 2026, Mozilla will require all extensions to adopt this framework. But don't worry, we'll give plenty of notice via the add-ons blog.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first half of 2026 ended in June. The add-ons blog hasn't mentioned it since, and nothing's changed. AMO's API reports the key for every add-on, so I went through the top 1,000 by users to see how far the labels have actually got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updated since July, and still nothing
&lt;/h2&gt;

&lt;p&gt;Of the top 1,000, 776 show nothing. Most of those haven't shipped a new version since November, so they never had a reason to add the key. The 21 made since November are fine, 20 of them declare something, which is what happens when it's actually enforced. So I split the other 979 by when their current version was uploaded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Current version uploaded&lt;/th&gt;
&lt;th&gt;Says something&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before 3 Nov 2025&lt;/td&gt;
&lt;td&gt;2 of 475 (0.4%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Nov 2025 to 30 Jun 2026&lt;/td&gt;
&lt;td&gt;57 of 161 (35%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Since 1 Jul 2026&lt;/td&gt;
&lt;td&gt;145 of 343 (42%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adblock Plus, AdGuard, Dark Reader, DuckDuckGo, Ghostery, NoScript, 1Password, MetaMask and Bitwarden have all shipped since July, and none of them say anything. I downloaded each one to check the API wasn't lying, and the key just isn't in their manifests. They're 9 of the 198 in that last row that left it out, and those 198 have about a third of the top 1,000's daily users between them.&lt;/p&gt;

&lt;p&gt;That's Mozilla's deadline, not theirs. Nobody told them to add it (more on that next), and silent doesn't mean they're collecting anything. uBlock Origin and Privacy Badger both bothered to say "none". It just means the prompt can't tell you either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing makes them
&lt;/h2&gt;

&lt;p&gt;Why haven't they? Mostly beacuse they don't have to. AMO's upload check &lt;a href="https://github.com/mozilla/addons-server/blob/2571f276a22ea251e34e71c82ba3f5ae3f40449d/src/olympia/devhub/tasks.py#L402-L429" rel="noopener noreferrer"&gt;upload check&lt;/a&gt; only turns a missing key into an error when it's a brand new add-on uploaded through the developer hub. An update gets a warning, and that &lt;a href="https://github.com/mozilla/addons-linter/blob/1850fb845eccd80df0e16c6bc068c347d13f3c66/src/messages/manifestjson.js#L902-L913" rel="noopener noreferrer"&gt;warning&lt;/a&gt; still says the key "will be required for new versions of existing extensions in the future". Mozilla's &lt;a href="https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/" rel="noopener noreferrer"&gt;docs&lt;/a&gt; say older add-ons "will have to at a later date", with no date.&lt;/p&gt;

&lt;p&gt;The nearest thing to news is &lt;a href="https://discourse.mozilla.org/t/the-new-data-transmission-permissions-seem-to-be-misleading-to-users/149410/6" rel="noopener noreferrer"&gt;a reply&lt;/a&gt; from Mozilla's add-ons team on thier forum, on 10 September:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Even though we haven't retroactively asked all the published Add-ons to comply with this policy, we do it every time they submit a new version/update.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From the code, I think "asking" there is that warning. The same reply says "I can't promise anything specific right now".&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest costs you
&lt;/h2&gt;

&lt;p&gt;When an older add-on adds the key for the first time and says it collects something, every existing user gets asked to agree, and if they say no the update doesn't install. Saying "none" doesn't do that. Firefox has a way round it: a &lt;code&gt;has_previous_consent&lt;/code&gt; flag that says users already agreed to whatever the add-on did before, so the update &lt;a href="https://github.com/mozilla-firefox/firefox/blob/6210f73af7ef7df6878d8f2d78599bf73e39c5ca/toolkit/mozapps/extensions/AddonManager.sys.mjs#L1311-L1326" rel="noopener noreferrer"&gt;goes through without a prompt&lt;/a&gt;. But AMO's linter has rejected that flag since October 2025, in a change titled &lt;a href="https://github.com/mozilla/addons-linter/pull/5807" rel="noopener noreferrer"&gt;"Prevent the use of has_previous_consent for now"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Return YouTube Dislike found out what that means. It's got about 900,000 daily users, and on 7 September it shipped its first update in nearly two years, declaring personally identifying information, browsing activity, website content and website activity. Its developer says all it gets is a random user ID and the IDs of the videos, which is how it counts dislikes in the first place. That sounds like a lot for a dislike button, but Mozilla's &lt;a href="https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/" rel="noopener noreferrer"&gt;examples&lt;/a&gt; for personally identifying information include "ID numbers", and browsing activity covers even "domains", so I can see how it got there. Here's what happened on &lt;a href="https://addons.mozilla.org/en-US/firefox/addon/return-youtube-dislikes/reviews/" rel="noopener noreferrer"&gt;its reviews page&lt;/a&gt;:&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%2F6f907ujq7omjg4eaw32g.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%2F6f907ujq7omjg4eaw32g.png" alt="One-star reviews of Return YouTube Dislike on AMO" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-star reviews of Return YouTube Dislike on AMO&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/firefox-add-ons-data-labels/one-star-weeks.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A week at a time, from Mondays, up to 11 October. Version 4.0.6 came out on 7 September&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It went from about one a week to 101 in a week. "Spyware", "data mining scam", "At what point does a youtube dislike add-on need my personal data?". One reviewer was sure the developer's account had been hacked. My favourite was this one, which sort of gets it and still gives one star:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The fact that the data processing method hasn't changed means it was being collected all along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be fair to the reviewers, the same update added premium statistics (likes over time, maps by country), and some of the reviews are about that. But about half of the 224 one-star reviews since 7 September mention the data or the permissions, some quoting Firefox's prompt word for word. The developer &lt;a href="https://discourse.mozilla.org/t/the-new-data-transmission-permissions-seem-to-be-misleading-to-users/149410/2" rel="noopener noreferrer"&gt;put it&lt;/a&gt; on Mozilla's forum as "calling my reviews section a shitstorm is an understatement".&lt;/p&gt;

&lt;p&gt;The first post in that same thread is from SponsorBlock's developer. It's a similar sort of add-on, talking to its own server about YouTube videos, and they were working out which boxes they'd have to tick. Probably browsing activity, they thought, maybe website content. SponsorBlock's current version came out in July and doesn't declare anything, so its 820,000 daily users get a nice clean prompt.&lt;/p&gt;

&lt;p&gt;So nothing makes the quiet add-ons say anything, and the flag that would let the honest ones say it without a fight has been blocked by AMO "for now" for a year. Untill one of those changes, the add-ons that tell you the most look the worst, and the ones that tell you nothing look fine.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>firefox</category>
      <category>browserextensions</category>
      <category>mozilla</category>
    </item>
    <item>
      <title>Dependabot's now a month late on Python and Go CVEs</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:52:53 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/dependabots-now-a-month-late-on-python-and-go-cves-2ig4</link>
      <guid>https://dev.to/notaflightrisk/dependabots-now-a-month-late-on-python-and-go-cves-2ig4</guid>
      <description>&lt;p&gt;Django put out &lt;a href="https://www.djangoproject.com/weblog/2026/aug/04/security-releases/" rel="noopener noreferrer"&gt;a security release&lt;/a&gt; on 4 August. One of the fixes, CVE-2026-15307, stops staff users writing files to disk through GeoDjango lookups, which Django says could be "in some cases enabling remote code execution". Here's what GitHub's advisory API says about it:&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;$ &lt;/span&gt;gh api /advisories/GHSA-wvqv-fj8w-qmhm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'{nvd_published_at, published_at, github_reviewed_at}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"github_reviewed_at"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-09-30T23:25:46Z"&lt;/span&gt;,
  &lt;span class="s2"&gt;"nvd_published_at"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-08-04T17:16:46Z"&lt;/span&gt;,
  &lt;span class="s2"&gt;"published_at"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-08-04T18:31:27Z"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was public and on GitHub within an hour and a half. Then it waited 57 days for someone at GitHub to review it, and until then Dependabot doesn't tell anyone. Trivy and Grype didn't spot the ones still waiting either, which I'll get to.&lt;/p&gt;

&lt;p&gt;GitHub did warn about this. Its &lt;a href="https://github.blog/security/supply-chain-security/inside-the-advisory-database-and-what-happens-when-vulnerability-volume-breaks-records/" rel="noopener noreferrer"&gt;June post on the advisory backlog&lt;/a&gt; told Dependabot users:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New advisories may take longer to trigger, with critical issues prioritized.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted to know how much longer, and whether criticals really do go first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a CVE gets to Dependabot
&lt;/h2&gt;

&lt;p&gt;There are two usual ways into GitHub's Advisory Database. A maintainer can publish an advisory on their own repo, and GitHub reviews that. Or someone else publishes the CVE (Apache, the Django team, the Go team, VulnCheck, a bug bounty platform), NVD lists it, and GitHub &lt;a href="https://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories-from-the-github-advisory-database/about-the-github-advisory-database" rel="noopener noreferrer"&gt;imports it automatically&lt;/a&gt; as "unreviewed". An unreviewed advisory doesn't say which package or versions it's about, and Dependabot &lt;a href="https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts" rel="noopener noreferrer"&gt;only alerts on reviewed ones&lt;/a&gt;, so nothing happens until a curator fills that in.&lt;/p&gt;

&lt;p&gt;Django's went the second way, and that's the route most of this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  May fell off a cliff
&lt;/h2&gt;

&lt;p&gt;Here's the share of PyPI and Go CVEs on that route that GitHub reviewed within 30 days of NVD listing 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%2F3cfm741bu7r7w4m8ctll.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%2F3cfm741bu7r7w4m8ctll.png" alt="NVD-route CVEs reviewed within 30 days, PyPI and Go" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NVD-route CVEs reviewed within 30 days, PyPI and Go&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/nvd-cves-reach-dependabot-late/within-30-days.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;By the month NVD listed them. September's left out, as by 15 October PyPA and the Go team had only&lt;br&gt;
  added 3 from its first fortnight&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every month from May 2025 to April 2026 was somewhere between 73 and 100%. Then May dropped to 57%, and June and July didn't make it to one in ten. Across May to mid-September it's 31%, against 88% for the same months last year. August's a bit better at 24%, but that's only 37 CVEs and nearly all of them Python, so I wouldn't call it a recovery yet.&lt;/p&gt;

&lt;p&gt;It's both ecosytems at once, too. PyPI went from 81% to 34%, and Go from 96% to 27%. The typical wait went from same-day to 36 days.&lt;/p&gt;

&lt;p&gt;You can't see GitHub's queue from its API, as unreviewed advisories don't name a package, so I came at it from the other end. PyPA and the Go team keep their own vulnerability databases on &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;OSV&lt;/a&gt;. I took every CVE from 2024 onwards in those, kept the ones that came in through NVD and not a repo advisory, and looked up when GitHub reviewed each. That's about 2,000 CVEs, 450 of them from this May to mid-September. I left out Go's standard library, since GitHub doesn't review those in any year, and openssl-encrypt, a small Python package that got 50 CVEs in two batches in August and would have dragged that month down on its own. npm and Maven don't have a database of their own outside GitHub's, so I can't do this for them. They might be fine, I've no idea.&lt;/p&gt;
&lt;h2&gt;
  
  
  Criticals wait just as long
&lt;/h2&gt;

&lt;p&gt;So, "critical issues prioritized":&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%2F0wf2754fv6ilyvdk04zv.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%2F0wf2754fv6ilyvdk04zv.png" alt="Reviewed within 30 days, by severity" width="800" height="303"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reviewed within 30 days, by severity&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/nvd-cves-reach-dependabot-late/by-severity.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;15 of 20 critical, 35 of 40 high and 126 of 140 medium or low CVEs last year,&lt;br&gt;
  then 22 of 70, 44 of 130 and 77 of 254 this year&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Criticals were the slowest group last year too, so I doubt it's new. Seven critical &lt;code&gt;golang.org/x/crypto&lt;/code&gt; CVEs NVD listed on 22 May (one is the SSH agent &lt;a href="https://github.com/advisories/GHSA-jppx-rxg9-jmrx" rel="noopener noreferrer"&gt;not enforcing&lt;/a&gt; a key's confirm-before-use constraint) were all reviewed on 25 June, 34 days later. Maybe criticals do jump the queue for other kinds of advisory, but for these I cant see it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Big projects too
&lt;/h2&gt;

&lt;p&gt;I half expected a long tail of obscure stuff nobody installs. Here's the share of each project's NVD-route CVEs reviewed within 30 days:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;2024-25&lt;/th&gt;
&lt;th&gt;2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mattermost&lt;/td&gt;
&lt;td&gt;154/155&lt;/td&gt;
&lt;td&gt;18/42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache Airflow&lt;/td&gt;
&lt;td&gt;27/27&lt;/td&gt;
&lt;td&gt;5/48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gitea&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;0/15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;golang.org/x/crypto&lt;/td&gt;
&lt;td&gt;3/5&lt;/td&gt;
&lt;td&gt;0/14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;26/26&lt;/td&gt;
&lt;td&gt;3/13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2026 column is May to mid-September again. Airflow there includes its provider packages, and Mattermost all its modules and plugins. Airflow core on its own went from 21 of 21 to none of 34.&lt;/p&gt;

&lt;p&gt;They do get there in the end. 99% of June's CVEs have been reviewed by now, 83% of July's and 73% of August's. 36 from May to mid-September still haven't, and when I checked them all again on 16 October, none had moved.&lt;/p&gt;
&lt;h2&gt;
  
  
  Repo advisories wait too
&lt;/h2&gt;

&lt;p&gt;The other route isn't any quicker. Once a maintainer publishes an advisory, GitHub's &lt;a href="https://docs.github.com/en/code-security/concepts/vulnerability-reporting-and-management/repository-security-advisories#dependabot-alerts-for-published-security-advisories" rel="noopener noreferrer"&gt;docs&lt;/a&gt; say reviewing it and sending Dependabot alerts "can take up to 72 hours". In September someone from GitHub's community team &lt;a href="https://github.com/orgs/community/discussions/207039#discussioncomment-18358374" rel="noopener noreferrer"&gt;replied&lt;/a&gt; to a thread about slow CVE requests:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;we're currently processing records in the order they're received, with a turnaround of approximately five weeks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And personally, since mid-August maintainers have published 58 of the &lt;a href="https://peng.ly/security-reports" rel="noopener noreferrer"&gt;advisories I've reported&lt;/a&gt;, 51 of them for packages Dependabot covers. GitHubs reviewed 9 so far, which took anywhere from 6 to 38 days, and the oldest one still missing from its database went public on 14 August.&lt;/p&gt;
&lt;h2&gt;
  
  
  NIST changed something the same fortnight
&lt;/h2&gt;

&lt;p&gt;Why now? Tbh I don't know. GitHub says that "since mid-April" it hasn't consistently met its own goals for publishing. On 15 April, NIST &lt;a href="https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth" rel="noopener noreferrer"&gt;changed how NVD works&lt;/a&gt;: it now only adds its own details, like severity scores and product lists, to CVEs that meet certain criteria (actively exploited, used by the US government and so on), and the rest wait.&lt;/p&gt;

&lt;p&gt;So maybe GitHub's curators leaned on NVD's details and lost them when NIST stopped. Or maybe they're just burried under everything else coming in - GitHub's June post says its CNA got almost 4,000 CVE requests in May alone, nearly ten times the year before. My repo advisories come with the package and versions already filled in by the maintainer, so they don't need anything from NVD, and they're slow too. That makes me lean towards the queue, but I can't prove it from out here.&lt;/p&gt;

&lt;p&gt;Quite honestly, this isn't good enough. GitHub - you need to do better. Millions of downstream apps trust you with their security, and you're taking weeks or months to send out Dependabot alerts for known critical bugs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trivy and Grype don't see them either
&lt;/h2&gt;

&lt;p&gt;On 15 October I took 16 of the still-unreviewed PyPI CVEs, pinned each one's affected version in a &lt;code&gt;requirements.txt&lt;/code&gt;, and ran &lt;a href="https://github.com/aquasecurity/trivy" rel="noopener noreferrer"&gt;Trivy&lt;/a&gt; 0.76 and &lt;a href="https://github.com/anchore/grype" rel="noopener noreferrer"&gt;Grype&lt;/a&gt; 0.120 on default settings. Neither found a single one. Grype with CPE matching turned on found 5. &lt;a href="https://github.com/google/osv-scanner" rel="noopener noreferrer"&gt;osv-scanner&lt;/a&gt; checks OSV, which has PyPA's database in it, and found all 16, which is a bit of a given since that's where I got them from, but thats also the point. If PyPA has it, osv-scanner will tell you, and it's one line to try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osv-scanner scan &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go's less clear cut. Go's own database had 83% of these CVEs within a month last year and 19% this year, so &lt;code&gt;govulncheck&lt;/code&gt; isn't any quicker than GitHub for these right now.&lt;/p&gt;

&lt;p&gt;I'll run it all again in November and see whether the wait's back under a month.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>github</category>
      <category>go</category>
    </item>
    <item>
      <title>Your money input probably thinks 5,04 is 504</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:52:22 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/your-money-input-probably-thinks-504-is-504-4jib</link>
      <guid>https://dev.to/notaflightrisk/your-money-input-probably-thinks-504-is-504-4jib</guid>
      <description>&lt;p&gt;Type 5,04 in here, the way most of Europe writes five euros and four cents:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.peng.ly/money-parsers-read-5-04-as-504/" rel="noopener noreferrer"&gt;Try it on the blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Chrome with its menus in English, the comma never goes in. You're left with 504, and nothing anywhere says anything went wrong. Firefox tells you it's not a number, which is at least honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome bins the comma as you type
&lt;/h2&gt;

&lt;p&gt;Blink &lt;a href="https://github.com/chromium/chromium/blob/151.0.7922.34/third_party/blink/renderer/core/html/forms/number_input_type.cc#L244-L247" rel="noopener noreferrer"&gt;checks each keystroke&lt;/a&gt; against &lt;code&gt;0123456789.Ee-+&lt;/code&gt; plus the locale's own number characters, and the code that &lt;a href="https://github.com/chromium/chromium/blob/151.0.7922.34/third_party/blink/renderer/platform/text/platform_locale.cc#L280-L290" rel="noopener noreferrer"&gt;builds that list&lt;/a&gt; leaves one out on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// We don't accept group separators.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;kGroupSeparatorIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decimal_symbols_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In English the comma is the group separator, so it's gone before your page ever sees it. &lt;code&gt;input.value&lt;/code&gt; is "504" and &lt;code&gt;validity.badInput&lt;/code&gt; is false, so there's nothing to catch.&lt;/p&gt;

&lt;p&gt;The locale is Chrome's own menu language, not your page's. An &lt;code&gt;&amp;lt;input lang="de-DE"&amp;gt;&lt;/code&gt; in an English Chromium still gave me 504, and starting Chromium in German gave 5.04. Firefox goes by the page's &lt;code&gt;lang&lt;/code&gt; instead, so it's bad input on an English page and 5.04 on a German one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The libraries do it too
&lt;/h2&gt;

&lt;p&gt;Last week someone in Costa Rica &lt;a href="https://news.ycombinator.com/item?id=50121562" rel="noopener noreferrer"&gt;posted on Hacker News&lt;/a&gt; about being charged $504 for $5.04 of API credit. Another commenter, in Germany with their Linux set to English, said they've ended up selling things on Amazon and eBay "for 599,00 instead of 5.99".&lt;/p&gt;

&lt;p&gt;How many of the libraries people put on money fields would do the same? I took the 20 most downloaded npm packages that turn a typed amount into a number (parsers, masks and input components), set no locale or separator options, and ran them in an English browser on an English page. Inputs and masks got typed into a key at a time (all but ngx-mask in Chromium and Firefox), and the plain parsers I just called.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;5,04&lt;/th&gt;
&lt;th&gt;1.234,56&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;@internationalized/number&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-number-format&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rc-input-number&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@rc-component/input-number&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;numeral&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;currency.js&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-currency-input-field&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;numbro&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;accounting&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cleave.js&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;text-mask-addons (with decimals on)&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;currency-formatter&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;autonumeric&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;imask&lt;/td&gt;
&lt;td&gt;5.04&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-imask&lt;/td&gt;
&lt;td&gt;5.04&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ngx-mask&lt;/td&gt;
&lt;td&gt;5.04&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vue-currency-input&lt;/td&gt;
&lt;td&gt;5.04&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inputmask&lt;/td&gt;
&lt;td&gt;5.04&lt;/td&gt;
&lt;td&gt;1.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;globalize&lt;/td&gt;
&lt;td&gt;NaN&lt;/td&gt;
&lt;td&gt;NaN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;parse-decimal-number&lt;/td&gt;
&lt;td&gt;NaN&lt;/td&gt;
&lt;td&gt;NaN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;13 of the 20 make 504 of it, and they're not obscure ones. &lt;a href="https://www.npmjs.com/package/@internationalized/number" rel="noopener noreferrer"&gt;@internationalized/number&lt;/a&gt; is Adobe's, and it's what React Aria's NumberField parses with. rc-input-number and its renamed successor are what Ant Design's InputNumber is built on.&lt;/p&gt;

&lt;p&gt;The German amount is worse. 18 of the 20 turn a thousand-odd euros into one and a bit, and I've no idea where inputmask's 1.56 comes from. Only globalize and parse-decimal-number say no to both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 504
&lt;/h2&gt;

&lt;p&gt;In US English a comma groups thousands, so the quick way to read "$1,234" is to delete the commas, and that's what most of these do. rc-input-number's whole default parser is &lt;a href="https://github.com/react-component/input-number/blob/v9.5.0/src/InputNumber.tsx#L217-L218" rel="noopener noreferrer"&gt;one line&lt;/a&gt;, with a comment on what it's for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [Legacy] We still support auto convert `$ 123,456` to `123456`&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parsedStr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\w&lt;/span&gt;&lt;span class="sr"&gt;.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But nobody in the US writes 5,04. Groups come in threes, so a comma with two digits after it can't be grouping anything. It's someone typing a decimal the other way round. globalize and parse-decimal-number check for that, and nothing else does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; currency('5,04').value
504
&amp;gt; numeral('5,04').value()
504
&amp;gt; new NumberParser('en-US').parse('5,04')
504
&amp;gt; parseDecimalNumber('5,04')
NaN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A German browser just flips it
&lt;/h2&gt;

&lt;p&gt;Two of the 13 only made 504 because my browser was in English. react-currency-input-field and React Aria's NumberField (the field on top of @internationalized/number) take their separators from the browser's language when you don't give them any. In a German browser they read 5,04 fine, and turn 5.04 into 504 instead. The Costa Rica poster says that's their case, with settings that use a comma and 5.04 typed in (the company's reply in the thread says 5,04, so I'm not sure which).&lt;/p&gt;

&lt;p&gt;Every library's locale option works, for what it's worth. Give react-number-format or rc-input-number &lt;code&gt;decimalSeparator=","&lt;/code&gt;, or react-currency-input-field a German &lt;code&gt;intlConfig&lt;/code&gt;, and a typed 5,04 comes out as 5.04. That just moves the problem to whoever types a dot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching it
&lt;/h2&gt;

&lt;p&gt;First you need what they actually typed, so it's a text input rather than &lt;code&gt;type="number"&lt;/code&gt;, since Chrome's binned the comma by the time you look. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode" rel="noopener noreferrer"&gt;&lt;code&gt;inputmode="decimal"&lt;/code&gt;&lt;/a&gt; gets phones a number pad. Then check it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commas only ever group threes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksWrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;typed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/,&lt;/span&gt;&lt;span class="se"&gt;(?!\d{3}(\D&lt;/span&gt;&lt;span class="sr"&gt;|$&lt;/span&gt;&lt;span class="se"&gt;))&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;typed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches 5,04, 599,00 and 1.234,56 and lets 1,234.56 through. Swap the comma for a dot on a German page. When it fires, ask what they meant rather than charging them for it.&lt;/p&gt;

&lt;p&gt;It won't catch 5,040, which comes out as five thousand and forty here and in globalize and parse-decimal-number too. Nothing can tell that one from the string.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>forms</category>
      <category>chrome</category>
    </item>
    <item>
      <title>New Python packages picked hatchling, not uv_build</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:51:51 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/new-python-packages-picked-hatchling-not-uvbuild-1lk6</link>
      <guid>https://dev.to/notaflightrisk/new-python-packages-picked-hatchling-not-uvbuild-1lk6</guid>
      <description>&lt;p&gt;On Monday, Michał Górny from Gentoo posted &lt;a href="https://blogs.gentoo.org/mgorny/2026/10/12/a-plea-not-to-use-uv-build/" rel="noopener noreferrer"&gt;a plea not to use uv-build&lt;/a&gt;. It's a Rust backend with 1.2-1.5 MiB wheels and a dozen releases a month, and Gentoo users have to build every one of them from source, all so it can zip up some pure Python files. Fair enough really.&lt;/p&gt;

&lt;p&gt;But it made me wonder how many people acutally use it. uv is everywhere now, and since &lt;a href="https://github.com/astral-sh/uv/releases/tag/0.8.0" rel="noopener noreferrer"&gt;uv 0.8&lt;/a&gt; in July 2025, &lt;code&gt;uv init --package&lt;/code&gt; hands you uv_build out of the box. So surely that's what new packages are built with?&lt;/p&gt;

&lt;p&gt;Nope. About one in twenty. Here's what new PyPI projects have been using each month:&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%2F2pl6zeh921npzwsxubqr.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%2F2pl6zeh921npzwsxubqr.png" alt="Build backend of new PyPI projects" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build backend of new PyPI projects&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/new-pypi-picked-hatchling/share.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Share of each month's sample of new projects, read from the first source release they uploaded&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The one that moved is hatchling. It sat at about 20% from June to November last year, jumped to over a third by January, and since March it's been somewhere around 40%. Setuptools barely moved, and Poetry's backend went from about one in ten to almost nothing.&lt;/p&gt;

&lt;p&gt;To get these I took 400 new projects at random from a day or three of PyPI's &lt;a href="https://github.com/pypi/warehouse/blob/main/docs/dev/api-reference/xml-rpc.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; each month, and read the build backend out of the first source release each one uploaded. Anyone with five or more in a sample got dropped, so one person pushing out 90 near-identical packages doesn't count 90 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twice as many packages
&lt;/h2&gt;

&lt;p&gt;The chart hides the bigger change, though. Around the same time, PyPI started getting about twice as many new projects. &lt;a href="https://answer.ai/posts/2026-03-12-so-where-are-all-the-ai-apps.html" rel="noopener noreferrer"&gt;Answer.AI noticed too&lt;/a&gt;, with over 25,000 in March, which is about 800 a day. So here's each backend in new projects a day, before and after:&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%2Fl0f7qkl6435p7zwvp98x.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%2Fl0f7qkl6435p7zwvp98x.png" alt="New projects a day, by build backend" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New projects a day, by build backend&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/new-pypi-picked-hatchling/a-day.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All of them together went from 390 a day to 765&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Setuptools doubled, which is about what you'd expect from the thing that's been the default forever. Hatchling nearly quadrupled. Of the 375 or so extra projects a day, about 230 are on hatchling, and uv_build got about 20.&lt;/p&gt;

&lt;p&gt;And hatchling went first. In the January window PyPI was still only getting 445 new projects a day, about normal, but hatchling's share had already gone from 21% to 36%. The flood didn't show up untill February.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed over the winter
&lt;/h2&gt;

&lt;p&gt;So what happened between November and January? Not uv. Its two big changes were 0.8 in July 2025 (uv_build as the default) and &lt;a href="https://github.com/astral-sh/uv/releases/tag/0.12.0" rel="noopener noreferrer"&gt;0.12&lt;/a&gt; in July 2026 (every new project packaged by default), and hatchling's line is flat after both. Not the &lt;a href="https://packaging.python.org/en/latest/tutorials/packaging-projects/" rel="noopener noreferrer"&gt;PyPA packaging tutorial&lt;/a&gt; either, which "uses Hatchling by default" and has done since 2022.&lt;/p&gt;

&lt;p&gt;What did happen in the second half of November 2025 was &lt;a href="https://blog.google/products/gemini/gemini-3/" rel="noopener noreferrer"&gt;Gemini 3&lt;/a&gt;, GPT-5.1-Codex-Max and &lt;a href="https://www.anthropic.com/news/claude-opus-4-5" rel="noopener noreferrer"&gt;Claude Opus 4.5&lt;/a&gt;, all within about a week. Thats only timing. But think about what those models learned from. Years of the PyPA tutorial saying hatchling, plus &lt;code&gt;uv init --package&lt;/code&gt; saying it too, right up until 0.8 switched it.&lt;/p&gt;

&lt;p&gt;So what do they actually write today?&lt;/p&gt;

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

&lt;p&gt;I gave the current Claude models and Codex the same prompt this week, in an empty folder: "Reply with only the full pyproject.toml you would write for a brand-new pure-Python library called tidy-dates (a small date-parsing helper) that I want to publish on PyPI." Three runs each, then three more with "I manage everything with uv" added.&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;Plain&lt;/th&gt;
&lt;th&gt;Told I use uv&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5.5&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;td&gt;2 uv_build, 1 hatchling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5.5&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;td&gt;all uv_build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Haiku 5.5&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fable 5.1&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;td&gt;2 uv_build, 1 hatchling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-sol&lt;/td&gt;
&lt;td&gt;all hatchling&lt;/td&gt;
&lt;td&gt;1 uv_build, 2 hatchling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fifteen out of fifteen picked hatchling when I didn't mention uv. Even telling them I use uv only got uv_build about half the time.&lt;/p&gt;

&lt;p&gt;Then have a look at the versions. When Fable went for uv_build, it pinned it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[build-system]&lt;/span&gt;
&lt;span class="py"&gt;requires&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;["uv_build&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="s"&gt;"]&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;build-backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"uv_build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 0.8 is from July 2025, and uv's on 0.13.1 now. The other Claude models capped it somewhere between 0.10 and 0.12, so none of them would take the current one. Their hatchling pins ran from 1.21 to 1.27, the newest of which came out in December 2024, with hatchling on 1.32 today. The only recent pin was Codex's &lt;code&gt;uv_build&amp;gt;=0.12.12,&amp;lt;0.13&lt;/code&gt;, and that was on the one run where it went and searched uv's docs first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who wrote them, though?
&lt;/h2&gt;

&lt;p&gt;That I can't tell you. A source tarball doesn't say whether a person or an agent made it. I did try: repos that have an agent file like &lt;code&gt;AGENTS.md&lt;/code&gt; or &lt;code&gt;CLAUDE.md&lt;/code&gt; in them today use hatchling more (55% against 37% without, for projects from this September). But hatchling went up nearly as much in repos without those files, and an agent doesnt have to leave a file behind, so files only get you so far.&lt;/p&gt;

&lt;p&gt;What I can say is the new hatchling projects look less like uv projects, not more. In mid-2025, 35-50% of hatchling source releases had a &lt;code&gt;uv.lock&lt;/code&gt; inside. Now it's under a quarter.&lt;/p&gt;

&lt;p&gt;uv_build might be creeping up. It was 9% in the October window, and 8% in a fresh one I pulled for 12-14 October, its best apart from an odd 12% in February. That's a few hundred projects each, so it could just be noise, and for Gentoo's sake I hope it is.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>pypi</category>
      <category>uv</category>
    </item>
    <item>
      <title>Your GitHub cron is 20 minutes late, or six hours</title>
      <dc:creator>Iain</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/notaflightrisk/your-github-cron-is-20-minutes-late-or-six-hours-3ob7</link>
      <guid>https://dev.to/notaflightrisk/your-github-cron-is-20-minutes-late-or-six-hours-3ob7</guid>
      <description>&lt;p&gt;My &lt;a href="https://github.com/NotAFlightRisk/android-rom-compat" rel="noopener noreferrer"&gt;android-rom-compat&lt;/a&gt; repo pulls in new device data every Monday at &lt;code&gt;17 6 * * 1&lt;/code&gt;. I chose minute 17 since everyone knows not to use &lt;code&gt;:00&lt;/code&gt;, becuase top of the hour is when GitHub's busiest (according to their &lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule" rel="noopener noreferrer"&gt;workflow schedule&lt;/a&gt; docs).&lt;/p&gt;

&lt;p&gt;Here's when it actually ran:&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;$ &lt;/span&gt;gh run list &lt;span class="nt"&gt;--event&lt;/span&gt; schedule &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-R&lt;/span&gt; NotAFlightRisk/android-rom-compat &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--json&lt;/span&gt; createdAt &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].createdAt'&lt;/span&gt;
2026-10-12T15:33:46Z
2026-10-05T14:47:28Z
2026-09-28T14:02:11Z
2026-09-21T12:54:11Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do you see the issue? Yeah, against the 06:17 it asked for, it was six and a half hours late, then seven and three quarters, eight and a half, and over nine. Actually, seems it gets later every week.&lt;/p&gt;

&lt;p&gt;Another of my repos has the same cron, and on the 12th it started at 15:33:46 too, to the second. Both were stuck in the same queue, and the queue was nine hours long.&lt;/p&gt;

&lt;p&gt;So this got me curious, and I went and checked a few hundred other people's results...&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lanes
&lt;/h2&gt;

&lt;p&gt;Scheduled runs on public repos are all visible through the Actions API. So I took a random mix of active repos, from tiny to very popular, and measured every daily cron in them over the first two weeks of October. Here's how late each one typically ran:&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%2Fsemimlcfnvnfiiqky4f4.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%2Fsemimlcfnvnfiiqky4f4.png" alt="How late each daily schedule typically runs" width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How late each daily schedule typically runs&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/github-cron-two-lanes/medians.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Number of workflows, by their median lateness from 1 to 14 October&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nearly half run within the hour, usually about 20 minutes late. The rest are almost three hours late or worse, usually six. Interestingly, between one and two hours there's nobody at all, so it's basically two lanes with nothing in the middle.&lt;/p&gt;

&lt;p&gt;It's not a bad day here and there, either. A workflow that's six hours late on Monday is six hours late on Tuesday, and on Wednesday. They almost never swap lanes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets the slow lane
&lt;/h2&gt;

&lt;p&gt;Anything under a few thousand stars is more likely to be slow than not. Only the really big repos get prioritized&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%2F5kf5p73drqb9a7ohrf6n.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%2F5kf5p73drqb9a7ohrf6n.png" alt="Repos in the slow lane, by stars" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repos in the slow lane, by stars&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/github-cron-two-lanes/slow-by-stars.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Share of repos in my sample whose schedules run hours late&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Personal repos mostly get it worse than org ones. I doubt stars are what GitHub actually looks at. More likely it's how busy the repo is, or something else that comes with being popular, and you can't see that from outside, so tbh I dont know. It's not down to the plan you're on though - nextcloud has a 17-star repo in the slow lane and a 10k-star one in the fast lane, under the same org.&lt;/p&gt;

&lt;p&gt;And schedules on &lt;code&gt;:00&lt;/code&gt; were no more likely to be slow than the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started on 27 August
&lt;/h2&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%2Fa4ouwhj6wpdperbpml69.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%2Fa4ouwhj6wpdperbpml69.png" alt="Daily median lateness, in hours" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily median lateness, in hours&lt;/strong&gt; (&lt;a href="https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/github-cron-two-lanes/lanes-by-day.json" rel="noopener noreferrer"&gt;the data&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same workflows, grouped by the lane they're in now&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before the 27th, the repos that are slow now were often behind, but they were spread out, with no gap between them and the rest.&lt;/p&gt;

&lt;p&gt;On 26 August GitHub had an &lt;a href="https://stspg.io/pg14nv9m3095" rel="noopener noreferrer"&gt;Actions incident&lt;/a&gt;. The database behind the service that triggers workflows got overloaded, and the write-up says this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Several changes to improve the general scalability of this part of Actions were already complete and deploying to production. Rollout of those changes will be complete within the next 24 hours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;27 and 28 August were a mess for everyone, with nothing about it on the status page. Then from the 29th, one group went back to about 15 minutes late. The other hasnt had a day under three and a half hours since, and it's getting worse - about four and a half hours in early September, six by October.&lt;/p&gt;

&lt;p&gt;I can't tell you if the rollout caused it. The timing matches, and that's all I've got. Someone else &lt;a href="https://github.com/orgs/community/discussions/207370" rel="noopener noreferrer"&gt;noticed schedules going missing&lt;/a&gt; on those same dates, and five weeks on the only reply is from GitHub's bot. It's not totally new either - back in July &lt;a href="https://github.com/orgs/community/discussions/201738" rel="noopener noreferrer"&gt;this thread&lt;/a&gt; had a brand new repo running 8-14 hours late almost every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;Run the &lt;code&gt;gh run list&lt;/code&gt; above on your own repo and compare it with your cron. Under an hour late, you're fine. Hours late every day and you're in the slow lane, and moving the minute won't get you out of it.&lt;/p&gt;

&lt;p&gt;If the time actually matters, stop asking &lt;code&gt;schedule:&lt;/code&gt; for it. Add &lt;code&gt;workflow_dispatch:&lt;/code&gt; to the workflow and kick it off from something that keeps time, like a cron on a box you already run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh workflow run import.yml &lt;span class="nt"&gt;-R&lt;/span&gt; you/your-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;a href="https://github.com/orgs/community/discussions/207370" rel="noopener noreferrer"&gt;the August thread&lt;/a&gt;, dispatched runs started in the same minute 209 times out of 209.&lt;/p&gt;

&lt;p&gt;My own Monday import is still on &lt;code&gt;17 6 * * 1&lt;/code&gt;, mind. It's device data, and nobody's waiting on it at quarter past six on a Monday.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>githubactions</category>
      <category>cron</category>
    </item>
  </channel>
</rss>
