Changelog

All versions and downloads Jump to Added | Changed | Fixed | Security

Unreleased [Not released yet]

Part 10 of 11. Previous part

Security (99)

  • wisp::untoken_once no longer fails for every purpose when one purpose fills the store of used tokens. Before, 65,536 live used tokens, for example from a flood of reset links, made each new untoken_once fail. Now a full store deletes the used tokens that expired (at most once a second). Each purpose can hold 16,384 live used tokens. A purpose at its cap refuses new tokens (400) and the other purposes keep working. A used token is never forgotten, so a replay stays refused.
  • wisp build and wisp build --static refuse a #[remote(prerender)] URL that leaves the output folder (.., a backslash, a drive or an empty segment). Before, the build wrote the answer at that path. The write fails with the export refuses to write ....
  • Two #[validate] attributes on one action parameter now check all their rules, as one attribute with all of them. Before, the build kept only the last one, so #[validate(len = 1..=20)] #[validate(email)] name: String checked only email. with and pattern can repeat, and each one runs. Any other rule that is twice on one parameter or #[derive] field fails the build: len is twice in the #[validate] of name: keep one .
  • A const SIGNED_IN: bool = true; in a +server.rs now gates its endpoints. Before, the build read it nowhere, and signed-out clients got the answer. Its OpenAPI operations now name the session and the 401.
  • An ICO entry that holds a BMP is sized by its DIB header, not only by its directory entry. A GIF is sized by its largest image frame, and a frame over 8192 by 8192 pixels is refused. Image refuses them with a 422, as it refuses a large PNG.
  • /_img keys a remote picture by its URL without the query and fragment, and fetches that same URL. It never sends the query to the origin, so new query values do not fetch, decode or fill the cache again, and a signed URL does not work through /_img. It also refuses a picture that decodes to more than 160 MB (40 megapixels at 4 bytes a pixel), and sets max_alloc of the decoder to twice that limit. A 16-bit RGBA PNG of 6000 by 6600 pixels (316 800 000 bytes) is now a 400, not a decode.
  • #[derive(Rest)]: a POST, PUT or PATCH sets at most 16 typed passwords in all, at any depth. A POST of an array to a type with a Password field takes at most 16 rows. More is a 413 (code too_many). Each typed password costs the worker about a fifth of a second to hash, so a large batch held the worker for a long time.
  • A raw-text tag name in a ${…} string, or a <textarea> with no close tag, no longer hides a later <form guard>, so the form keeps its token check.
  • <meta http-equiv> with a character reference (&#82;efresh) counts as refresh, so its content cannot hold an expression.
  • An env.PUBLIC_* value writes < as \u003c, so it never ends an inline <script>.
  • A newline in a route folder name stays inside each generated Rust note.
  • pulldown-cmark 0.13.5 for RUSTSEC-2026-0414. Wisp is not affected: wikilinks are off.
  • wisp ui Menu: the id reaches anchor-name and position-anchor only as letters, digits, - and _, as in Tooltip. An id with ; cannot add CSS.
  • wisp ui Scroll and Table: max_height keeps only 0-9, a-z, .%()+*/, space and -. A value with ; or : cannot add CSS.
  • #[json(skip)] on a field of a #[derive(Json)], #[model] or #[derive(Rest)] type keeps a secret out of the JSON. The field is not in a response, the TypeScript or the OpenAPI schema. FromJson does not read it from a body or a form, so a client cannot set it. It gets its #[json(default = ..)] value, else Default::default(). A saved table, a queued job, put_json/get_json of a KV store and the rows of all/first of a SQL database keep it. A #[derive(Rest)] filter or sort cannot name it, and a PUT or PATCH keeps its value. Before, #[derive(Json)] wrote every named field. An unknown key in #[json(...)], or #[json(...)] on a tuple field or an enum variant, stops the build. With #[cfg_attr(cond, json(skip))], the OpenAPI schema and the form leave the field out even when cond is false.
  • #[validate(pattern)]: the server reads an escape at the end of a class range as the browser does. [+-\.] is + to ., and [a-\d] stops the build. Before, the server took \ as the end and let more through.
  • #[validate(pattern)]: . takes no line end (\n, \r, U+2028, U+2029), as in the browser. Before, the server took a line end where the browser did not.
  • #[validate(pattern)]: ^ or $ in a group (x(^a), (a$)b) stops the build. Before, the server dropped them and took values that the browser refused.
  • A WebSocket on HTTP/2 (extended CONNECT) now gets the WISP_WS_IDLE ping and close (1001), as on HTTP/1.1. Before, a client that sent nothing and kept the connection alive with PING frames held the stream until the server stopped.
  • Netlify Blobs: a key with a . or .. segment is an error. Before, fetch resolved the segment and a key such as ../other could reach another store of the site.
  • CLI downloads (curl) stop at 512 MiB for a file and 64 MiB for a body in memory (--max-filesize). curl checks the size against Content-Length, so a response with no length is not capped.
  • wisp service install (Linux): the env file /etc/<name>.env is created with mode 0600 from the start, not changed after the write. An old file or a link at that path is deleted first.
  • Workflows from wisp deploy init run wrangler, vercel and netlify-cli at exact versions, not the newest at each push.
  • A server that starts as root with WISP_USER set and cannot find its own program stops. Before, it went on as root.
  • A full set of used guard nonces forgets those nearest their expiry, not a random half. A flood of forms cannot free a live nonce for a replay. A full set sweeps the dead at most once a second, not on each call.
  • A REST list query takes at most 16 sort keys and 16 filters. More is a 400 (code bad_query). A field sorted twice counts once. Before, a query with many keys held the table's read lock for a long time.
  • An SVG upload holds at most 32 namespace bindings in scope. Before, many bindings made each name look through thousands of them.
  • style={:{...}} drops a key that is not a property name (letters, digits, _ and -). A key from a visitor's object cannot write declarations of its own.
  • A string style={:s} keeps only the declarations that style=${s} keeps (StyleDecls) on the server's first paint. A browser update gives it to the browser's parser. Before, the first paint kept a visitor's expression(, \ escapes and comments. An object value with ;, { or }, or a key that is not a name, also stays out of the first paint, as in the browser.
  • Without SITE_URL, a release build takes no address from the Host header. It serves no made /sitemap.xml, /robots.txt or /feed.xml, and wisp::alternates gives nothing. Dev mode still uses the Host. The edge uses SITE_URL only. Before, a client's Host named every address in them.
  • A post that wisp.js sends and that answers with a file shows it at a blob address only for an image that is not SVG, audio, video or plain text. It saves the others as a download. A PDF or JSON answer is now a download too (before, the browser showed it). Before, an SVG or XML answer ran its script on the site's origin without the answer's CSP.
  • An offline data-wisp-queue form with a cc-*, one-time-code or *-password field is not kept in sessionStorage, as a form with a password field. It gets the offline message.
  • A page's RATE_LIMIT runs before a multipart upload streams to disk, also on a route with a +server.rs. Over HTTP/1.1, a spent limit gets its 429 with no 100 Continue, and the connection closes with the body unread. Over HTTP/2, the stream gets its 429 and a reset before any file part goes to disk. Before, a client over its limit could make the server stream the upload to disk first.
  • Under WISP_BASE, the session and flash cookies over HTTPS are __Secure-session, __Secure-wisp-flash and __Secure-wisp_oauth_<name> with Path the base. A sibling subdomain can set a __Secure- cookie. Before, they were __Host- cookies with Path=/, so two apps on one host shared them.
  • A route param keeps its raw text when an escape would add a /, a \ or a . or .. segment (%2F, %5C, %2E%2E), also after a rewrite or a reroute.
  • The fetch guard blocks an IPv6 address of the form ::ffff:0:a.b.c.d (SIIT, RFC 7915).
  • A command runs each query URL of x-wisp-refresh once. A repeated URL does not run the query again.
  • The release workflow builds the five release archives in a target folder of its own, after git clean -ffdx, and not in the target folder that pull request builds share on the runner. The crates job now waits for binaries, and verify runs cargo deny before anything is published.
  • The VS Code release passes its tokens by environment (VSCE_PAT, OVSX_PAT) and not by argument, and runs npm ci from the new editors/vscode/package-lock.json. In an untrusted folder (Workspace Trust), the extension starts no wisp command and keeps highlighting.
  • The docs deploy gives the Cloudflare token and account id to the Deploy step only. The build step no longer sees them.
  • The GitHub copy of docs-deploy.yml clones wyziedevs/wisp-docs, as the copy of ci.yml does, and not wispdev/wisp-docs. The copy of release.yml is valid YAML again (the docs job of the mirror had an unquoted : in a plain scalar).
  • The gate step grammar generate makes editors/tree-sitter-wisp/src again with the local tree-sitter command, version 0.25.10 only, and fails when a committed file differs, so a hand-edited parser.c cannot pass. Outside CI, a missing command skips the step and the end of the run says so (WISP_SKIP_GRAMMAR=1 skips it too). In CI, it fails. Neovim and Emacs pin the grammar by commit, as Zed and Helix do, and the grammar pins step checks all four and the pin on the design-editors docs page.
  • The Prettier plugin gives wisp fmt --stdin an absolute path, so a path that starts with - is not read as an option. With no wisp command, it fails in CI and warns elsewhere.
  • A push to main is no longer cancelled by a newer push (ci.yml). The daily audit job clones wisp-docs. The publish-check filter covers crates/** and llms/**.
  • Edge: wisp::edge::fetch and wisp::fetch_stream take http:// and https:// URLs only, as native wisp::fetch. Before, an app that fetched a URL from a client (wisp:store) could read every saved table row or reach a binding of the host.
  • Edge: cx.cf comes from the host only. Before, a client on the direct path of Node or a raw connection of Node, Bun or Deno could set it with an x-wisp-cf header.
  • wisp build checks each NPM file that it downloads from esm.sh against its SHA-256 in npm.lock, and fails on a mismatch. It adds a line for a new file. Commit npm.lock.
  • The wisp build cache marker (wisp-build-cache) is written with create_new, so a symlink at its name is not followed.
  • The self-hosted Forgejo CI no longer runs pull requests. Only commits on main run there. A break shows only after the merge to main. publish-check runs on each push to main that changes a manifest or crates/*/src/**.
  • Ask Wisp sends only the user turns of a chat to the model, because a client can forge an assistant turn. A follow-up question does not see the earlier answer.
  • wisp fmt skips a file that is a symbolic link and says so, because it wrote through the link, possibly outside the project. wisp ui add, wisp add, wisp openapi -o and wisp deploy init (with --force or not) refuse to write a path that is a symbolic link. wisp build no longer follows a linked folder when it looks for Open Graph calls in src: a folder linked to itself made the walk grow without end.
  • The CLI changes each control character (such as an escape byte) in a file name or a recipe note to ? before it shows the text, so a project folder cannot send commands to the terminal.
  • The published wisp-web crate builds on its own: build.rs took semver.rs from the wisp-shared folder beside it, which the package does not hold. It now uses wisp-web-shared as a build dependency.
  • The dev signing key .wisp/secret is created with create_new (mode 0600 on Unix) and is never written through a symbolic link. A link at .wisp/secret is not read: the server logs it, and a new random key replaces the link.
  • The server ends the connection of a client that reads a response a few bytes at a time. Each send must go at 1 KiB a second at least after a 30 s grace, the same floor as a request body. The rate counts the bytes that the kernel takes from the server, not the bytes that the client reads. Before, a client that took a byte every 29 s held the connection and its response buffer. On Linux, the server resets a connection it cuts this way, or for no progress in 30 s, so the kernel frees the queued response at once.
  • io_uring: an idle driver checks stalled sends once a second. Before, the check time moved on with each clock second and never came, so a client that took nothing held its connection.
  • io_uring: release builds also drop a completion with an old connection generation. Before, only a debug build checked it.
  • A page's or layout's SIGNED_IN runs before the server reads an upload that streams to disk, as RATE_LIMIT does. Before, a visitor who was not signed in could make the server read and spool the whole upload (up to the body limit) before it got the sign-in answer. Now the visitor gets the 303 (401 for JSON) at once and the server reads no body. Over HTTP/1.1 the connection closes. Over HTTP/2 the stream gets that status and a reset (a 303 has its location, a 401 has no JSON body). MIDDLEWARE and before still run after the read, as they can read the form. The order of two guards changed in one case: for a signed-out streamed upload, a MIDDLEWARE of a parent layout above a child's SIGNED_IN no longer runs first. The sign-in answer comes before it.
  • OpenID Connect sign-in (wisp::oauth) checks the id_token more (OIDC Core 3.1.3.7). The token must have an exp that is in the future. Before, an expired token passed. With many audiences, azp must name this client, and an azp that is there must name it in any case. The iss, aud, sub and nonce checks stay. A failure is the same 400 as before.
  • Svg refuses a namespace URI of more than 1024 bytes ("a namespace too long"). Before, the parser copied a 128 KB xmlns URI into each element name and prefixed attribute. One upload of about 200 KB then used about 2.5 GB of memory (20000 elements x 128 KB). A parsed element now holds a small namespace value, not a copy of the URI.
  • Svg writes an attribute only when its escaped value has at most 131072 bytes. Before, each " in a value grew to six bytes in the output. The stored Svg then failed to read back, and a saved table skipped the row after a restart.
  • The generated TypeScript client throws on a path piece that is . or ... Before, fetch dropped such a piece, and the request and its authorization token went to another path of the same origin.
  • The offline worker ("offline": true) no longer keeps a signed-in front page for the next person on the same browser. Before, it fetched / at install with cookies and kept the reply, also when the server sent private, no-store, and showed it offline. Now it fetches / without cookies and keeps it only when the reply is a status 200 text/html page with no redirect, no private or no-store, and no Vary except Accept-Encoding.
  • Edge WebSockets on Node, Bun and Deno (raw hosts): a client that leaves while the host is behind no longer leaves the handler task and its socket in memory. Before, a send that waited for the host never ended, so a client that made the host buffer full, read nothing and left kept the task and its inbox for ever.
  • Edge WebSockets on raw hosts: the socket drops the frames that the handler already read before it adds new bytes. Before, a client that kept a few frames unread made the buffer grow with all the traffic of the connection, with no limit.
  • A quote in a tag is a build error unless it follows = ("a quote inside an unquoted value or a name"). An = is an error unless it follows an attribute name ("an = that does not follow an attribute name"). A tag name that a byte other than a blank, / or > ends is an error (<img\0src=x>). A blank around = is allowed (alt = "x"). An unquoted value can hold = and / (href=/a?b=c), but /> ends it. A block or a hole right after an unquoted value is an error ("a block or hole right after an unquoted value: quote the value or write a blank before it"). The browser keeps the value going past it. Before, Wisp opened a quoted value at such a quote, but the browser kept the quote as text. So <img src=x alt=x' onerror=${m} '> put m in an unquoted onerror attribute that the browser ran. No hole check saw it.
  • A spread and an attribute hole (title=${t}, disabled=${d}) keep a blank between the text before and after them, so a name never joins an unquoted value, and an = right after ${...attrs} is a build error. A spread with no pairs, a None hole and a false boolean write nothing. Before, the build deleted the blank. So <a href=x ${...attrs}y=' onerror=${m} '> joined y= to the unquoted value, and the browser ran onerror.
  • A <form ACTION="?/del" GUARD> in capital letters now gets its trap check. Before, the build did not see the attributes, so the action del ran with no guard.
  • Edge builds send cache-control: private, no-store on a page for one visitor, as the native server does. The sources are: a signed cookie read, a flash shown, cx.nonce() and a <form once>. Before, the edge sent none (a <form once> page got private). The offline worker kept the page of a signed-in visitor. The next person on the browser could read it offline.
  • /feed.xml leaves out a Markdown page that its route keeps out of the sitemap (under (private), or noindex). Before, the feed listed its title, address and description to anyone.
  • wisp dev --https and proxy rules: a request with Upgrade: websocket no longer carries a second request to the app. Before, the front copied every byte that the client sent after the upgrade head straight to the app, before the app answered 101. If the app refused the upgrade, it kept the connection open and ran the pipelined request as a request from the developer. A peer on the network could then reach /_wisp/dev/swap, /_wisp/dev/module and /_wisp/dev/open. Now the front holds those bytes until the answer is 101, and closes both sides on any other answer. An upgrade request with a body gets a 400.
  • wisp dev --https: the plain side of each TLS session now accepts only the connection that Wisp made itself. Before, a local process that connected to the loopback port of the session first got the decrypted requests of the browser. They hold cookies. That process could also write the answer. Wisp now drops any other connection. After 16 such connections, the session fails.
  • wisp dev --https never writes over a certificate of your own in .wisp/dev-cert.pem. Before, a missing or unreadable dev-key.pem made Wisp replace that certificate with a new one.
  • wisp clean --cache deletes a folder only when it is a build cache. Before, a mistyped WISP_BUILD_CACHE could pass the checks and make the command delete files of the user. Now a build writes the marker only in a new or empty folder. The command resolves .., links and the case of a Windows path before it compares the folder with home and the current folder. It also refuses a folder that holds anything but the entries that cargo makes, and the error names the reason. A folder that does not exist gives "already empty".
  • The edge build with raw connections (Node, Bun, Deno) stops the output of a connection when the host says the client is behind, until the host pulls. Before, a client that sent many requests for large pages and read nothing made the host buffer grow without limit.

Next part (11 of 11)

Source: CHANGELOG.md