Changelog
0.4.0 - 2026-09-07
Added
- The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - Server
instructions. Results carry anuntrustedmarker, but that is read after the fact — this is the channel a model sees before it calls anything. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row. - A boundary between what the instance sends and what this server reasons about (
src/boundary.ts). Every response used to be a TypeScript cast, which is not a check, and two layers behind it are: the projections call.mapand.sliceon whatever arrived, and the SDK validatesstructuredContentagainst the schema each tool declares. One field of the wrong type therefore cost the whole answer — see Fixed. - One place where text from the instance is cleaned (
src/text.ts): control characters removed, lone surrogates repaired, quotations bounded and labelled. It covers the channels a projection never touched — an error body, a200that carries an error sentence, and the message a library wrote. actions/dependency-review-actionon pull requests.npm auditchecks the tree as it is; this checks the change a pull request makes to it, before it is merged.- A linear-time test file. Every function that runs a pattern over operator or instance text is timed at its ceiling, so the next regular expression gets its line before it gets merged.
Changed
docs/reference/tools.mdis written by hand again. It used to be generated from the registered tools, which kept it in step with the code at the price of a page nobody could edit:--checkcompared it byte for byte, so every line had to be derivable and a paragraph about how an endpoint really behaves had nowhere to go. A test now asserts what the generator guaranteed — the page documents exactly the tools that exist, marks exactly theessentialpreset, and marks exactly the tools that ask a person first — and leaves the prose to a person.homepageinpackage.jsonpoints at the documentation site rather than at the README anchor on GitHub. It is what npm shows next to the package, and every one of these servers has had a documentation site for weeks.- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller. - One shortener for both kinds of result, and it descends. There were two: one that dropped list entries, used by the write tools, and one that halved the longest top-level string, used by every read tool — and a listing has no top-level string, so the read side could not shorten anything at all. It now collects every long array and every long string in the answer, one level down as well, spends the largest savings first and measures once.
truncatednames the path it cut and how much. LINKWARDEN_URLis stored as the parsed URL rather than as the environment string, and a query or fragment is dropped with a warning saying so. Left on the value it was glued in front of every path:https://links.example.net/?debug=1becamehttps://links.example.net/?debug=1/api/v1/links/1, and every call failed in a way that named nothing.- The runtime image no longer ships yarn. npm and corepack were removed four releases ago; yarn lives in
/opt/yarn-v*with two shims in/usr/local/binand is a second package manager in an image whose entrypoint is plainnode. - The two channels of every tool are held to each other by a test over the catalogue:
JSON.parseof the text block equalsstructuredContent, with the one deliberate difference — the paragraph that says the JSON below came from a stranger's page — asserted where it belongs and forbidden everywhere else. - Dependency bumps:
mcp-tool-allowlist0.2.2,mcp-integration-harness0.4.1,oxlint1.82.0,@types/node26.5.0.
Security
- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - The access token could reach the model context through the transport's own error message.
fetchquotes what it refuses —Headers.append: "<value>" is an invalid header value.— and forAuthorizationthe value is the token. A token pasted across two lines is exactly that shape, andrun()'s generic catch handed the message on as a tool result. Verified on undici 8.10 and on Node's globalfetch. Three things now stand in the way: the value is checked at startup and the server refuses to start, naming the variable, the length and the position but never the value; every header is checked again in front offetch, because a configuration can be built without the startup path; and the configured token is removed from whatever a library still chose to quote. - Nothing cleaned the text the instance sent. An
ESCand aBELin a500body reached the tool result verbatim, and the sentence a200-with-an-error answer carries was quoted at whatever length it arrived in — up to the 8 MB read ceiling, into anisErrorresult that no budget measures. Both go through the cleaner now, bounded and labelled as the instance's words. - A failed response was read before its status was looked at. A reverse proxy answering
401with a login page over the 8 MB ceiling surfaced as "Linkwarden returned more than the 8388608 byte limit": the size rather than the status, no credential hint, and a plainErrorinstead of aLinkwardenApiError, so theinstanceofthat adds the hint did not match either. The status decides first; an error body has its own 64 KiB ceiling that cuts instead of refusing. npm ci --ignore-scriptsin the publish job, the one job that holds an OIDC token for npm Trusted Publishing. A dependency's install hook ran while that token could be minted. Nothing in this tree has one — checked withnpm query ':attr(scripts,[postinstall])'and its siblings — which is what makes the flag free.- Diagnostics no longer echo a value that failed a check. A 56-character hexadecimal key with a colon after it is a valid URL whose scheme is the key, and the "must use http:// or https:// (got …)" line printed it in full; the
ELICITATIONmessage printed its raw value. Both describe by length now, and quote only a short word-shaped value — a typo is a word, a pasted secret is not. - Trailing-slash normalisation was quadratic.
url.replace(/\/+$/, '')is tried from every position of the run when a character follows it: 20 000, 40 000 and 80 000 slashes cost 136 ms, 536 ms and 2.2 s. Operator input, so a small finding — and one line, an index walk. SECURITY.mdargued that the replay path was unreachable from a default protocol-version list that a hand-wired transport would have used. This server serves throughserveStdio, which negotiates2026-07-28. The section now says what actually closes it — the nonce inmcp-approval0.8.1 — and names the two residual limits.
Fixed
list_tagswas unusable for every client that reads tool schemas. ItsoutputSchemais a closed object —additionalProperties: false— and the handler answerednext_cursoron every call, including when it isnull. The server-side check strips the unknown key and passes; the client's check refuses the whole result with "Structured content does not match the tool's output schema". It had been that way since the schemas were added, and no test saw it because none of them listed the tools before calling one. The test harness now lists once per connection, which is what found it.- A single unexpected field from the instance took a whole answer down. With every response cast rather than read,
{"links": {}}wasslice is not a function,[null]in a page wasCannot read properties of null, a link whosetagsis an object wasmap is not a function, and"id": "5","name": 42,"isPrivate": "yes","nextCursor": 1e999or1.5were each anOutput validation errorfor the listing they appeared in. Eleven of them, over eight tools. A field of the wrong type is now absent, an entry that cannot be read is counted innotes, and a cursor that is not a safe integer reads as the end of the list. - A listing that was too large answered with an error instead of a page. 100 links, every field inside its own cap, are past the 200 000-character result budget — and the shortener behind the read tools had nothing it could cut. It is shortened now, and the follow-up sentence no longer says
call search_links again with cursor=null. - Confirmation sentences quoted numbers the instance chose without reading them as numbers. The collection id in
delete_link, the link count indelete_collectionandupdate_collection, and the host inrepreserve_linkare the instance's values in a sentence a person reads. They are read as safe integers — omitted when they are not — and the host is cleaned and bounded.
0.3.0 - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result — which seven of them made unavoidable, since they answered with a sentence. The sentence stays, in the text block.The ten reading tools carry
untrusted: trueandsource: "linkwarden"as fields. This server has always said so innotes, which is prose in a list: a client can read it and cannot check it. The write tools are without the marker — they report an id this server was given and a count it made.Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call
confirm_tokenremains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.rename_tagnow asks too. It was annotateddestructiveHint: trueand went through unannounced: one call, and every link that carries the tag follows. Linkwarden keeps no history of what a tag used to be called, and a saved search built on the old name simply stops matching. wikijs guardsupdate_tagfor the same reason.The approval is bound to the tag id and the new name, so one obtained for "rename 3 to reading" does not execute "rename 3 to archive".
ELICITATIONswitches the dialog off —falsesends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one
export ELICITATION=falsereaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neithertruenorfalsestops the server, where theLINKWARDEN_*booleans beside it fail off on a typo: this is the only variable here that defaults to on. It is read afterLINKWARDEN_TOKENis wiped from the environment, so that exit cannot leave the token behind.A
docs/guide/approval.mdpage, and a 👤 marker in the generated tool reference that is read off the registered schema rather than from a list kept beside it.
Changed
The advertised schemas avoid spellings that are legal JSON Schema and still get a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes
"additionalProperties": truerather than the empty schema{}zod emits for it; a value that was left untyped is declared as what it really is; and a nullable field is written asanyOfbranches rather than"type": ["string", "null"], which several clients read as a single type and then drop. What the tools accept and return is unchanged; only the way the schema says so is.Three refusals in
get_link_contentare error results rather than plain ones: no readable archive, an archive served with the wrong content type, and one that is not valid JSON. Each read like an answer while being the opposite.A result too large to shrink is an error rather than an envelope carrying the oversized document as a string. That envelope is valid JSON and no longer a valid answer: the SDK checks a result against the schema its tool declares.
The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. The text is unchanged and still carries the token.The integration compose file publishes Linkwarden on
LINKWARDEN_PORT(default 3010) instead of a hardcoded 3010, so a workstation that already runs something there does not need a patched compose file.smtp-mcphas done the same for its own port for a while.Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.
The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.The tool filter, the confirmation store, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather than from copies kept here — 1122 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.The shared libraries move to
mcp-approval0.7.1,mcp-tool-allowlist0.2.1,mcp-internal-hosts0.2.1,mcp-integration-harness0.2.0 andsvg-asset-set0.2.0. The harness change shows up in the suite: where a security path asserted only that a call failed, it now has to say why —expectError: trueis also satisfied by a schema rejection, so a renamed argument used to keep such a test green while the guard it names went unreached.SECURITY.mdnow says what the confirmation proves: binding to one operation with one set of arguments, not freshness. No replay defence is built, because the sealing key is per process, the token is single-use, andrequestStateonly crosses the wire on protocol revision2026-07-28, which this server does not offer — it takes the SDK's default list, which ends at2025-11-25. The section names what would have to change for that to stop being true.stdio is served through
serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wiredStdioServerTransportserved it.
Fixed
An oversized field no longer cuts the result mid-string.
untrustedResultcapped by slicing the serialized JSON. Readability copies<meta name="description">intoexcerpt, so a page the caller never chose to trust could put 260 kB there — and none of the six metadata fieldsget_link_contentreturns was clamped on that path. The answer was 200 kB of attacker-chosen text, no article, nonotesand nooffset, in JSON that no longer parsed: everything a model needed to recover came last and disappeared first.Two changes. The metadata now goes through the same
clampevery other path uses, so onlytextcan fill the budget andmax_charsalready bounds that. AnduntrustedResultshrinks the largest field of an envelope instead of the document, which is whatjsonResultbeside it has always done and says so in a comment.A corrupt readable archive is reported, not quoted.
get_link_contentchecked the content type and then calledJSON.parseunguarded. A body that claims JSON and is not — somethingapi.request()treats as a thing that happens — threw, andrun()answered with Node's parser message, which quotes about ten characters of the body. Those characters come from a saved foreign page and reached the model outside the untrusted wrapper the rest of the handler routes everything through.LINKWARDEN_READ_ONLYaccepts1,trueandyes, trimmed and case-insensitively, where it used to require the exact stringtrue. It fails towards the restriction, soLINKWARDEN_READ_ONLY=1silently registering the write tools is the one outcome it must not have.LINKWARDEN_INSECURE_TLSkeeps the exact-match rule, for the same reason read the other way round.docs/guide/security.mdlisted three of the four things the SSRF guard does not cover and left outrepreserve_link, although the 0.1.3 entry claims both files name it. It is there now.Confirmation tokens are compared with a constant-time comparison. The copy in this repository used
!==, which leaks through timing how much of a guess was right. Reaching a token still requires having received it in a previous tool result, so this closes a margin rather than a hole.An entry in
LINKWARDEN_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.LINKWARDEN_TOKENandLINKWARDEN_ALLOW_TOOLSare adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.
Security
update_linkandcreate_rss_subscriptionareopenWorldHint: true. Both hand Linkwarden an address the caller chose and have it fetch that page, which is the one thingcreate_linkwas called open-world for. They saidfalseon the reading that their usual call fetches nothing — but that is a property of a call and an annotation is a property of a tool, and the point of the hint is that a host can gate or sandbox such a tool before it sees the arguments.create_rss_subscriptionis the broader of the two: Linkwarden pulls the feed at once and then creates and archives a link for every entry.The test that pinned this asserted
tool.name === 'create_link'; it now compares the open-world set against the set of tools whose schema declares aurl, so the two cannot drift apart again.The
represerve_linkdialog names the host. A stored link can point athttp://10.0.0.1/status— it may have arrived through the web UI, an import or a subscribed feed, none of which this server saw — and re-archiving is a fresh outbound fetch of it.get_link_contentactively steers a model there ("callrepreserve_linkto have Linkwarden archive the page again"), and the question was "delete the preserved copies of link 42 and archive the page again", with no way to tell that apart from re-archiving a public page.Only the host, on the labelled "supplied by the caller" line.
delete_linkwithholds the title and the URL on purpose and still does: page prose does not belong in front of a person. The host is the part the answer turns on.rename_tag's confirmation key labels its targets.setResourceKeysorts its target list, andString(tag_id)erased the difference between an id and a name — so{tag_id: 7, name: "12"}and{tag_id: 12, name: "7"}produced the same fingerprint, one approval covering two different renames. Both pass the schema: a tag called "12" is legal and year or issue-number tags are ordinary. The targets are nowtag:<id>andname:<name>.
[0.2.0] - 2026-08-27
Added
LINKWARDEN_ALLOW_TOOLSandLINKWARDEN_DENY_TOOLSchoose which of the 28 tools are registered. Both take comma-separated tool names or a prefix with a trailing*(bulk_*), the allow list decides what is in and the deny list is subtracted from it, andLINKWARDEN_ALLOW_TOOLS=essentialselects a curated eight —search_links,get_link,get_link_content,list_collections,list_tags,create_link,update_link,delete_link. A model picks the right tool far more reliably from eight than from twenty-eight, and every visible tool costs context on every request. Nothing changes for an installation that sets neither: all 28 are still registered.A filtered tool is not registered at all, so it is absent from
tools/listand answerstools/callwith "tool not found" — the same cutLINKWARDEN_READ_ONLYalready makes, not a second, weaker one.An entry that matches no tool stops the server at startup, naming the entry and listing the real names, rather than being ignored: an ignored typo leaves a tool missing from
tools/listwith nothing pointing at the cause. The same applies to a malformed pattern such as*_link. UnderLINKWARDEN_READ_ONLY, an exact write-tool name in the allow list is refused with a message naming the read-only setting instead of calling the tool unknown, while a pattern covering write tools is accepted and simply contributes nothing.The tool reference marks the preset members, generated from the same constant the filter reads, so the two cannot drift apart.
Changed
- The README now carries the same eight badges, in the same order, as every other MCP server in this family, all of them reading from npm rather than hard-coded; the opening follows one shape; and the standalone "Full documentation" line is gone, because the docs badge three lines above it points at the same page.
Fixed
- The container image no longer ships OpenSSL 3.5.7-r0, which carries CVE-2026-14456 (denial of service via unbounded memory growth). The pinned
node:24-alpinedigest is already the newest one; Alpine's fixed 3.5.8-r0 has simply not been rebuilt into it yet, so the runtime stage now upgradeslibcrypto3andlibssl3by name. Upgrading those two rather than running a blanketapk upgradekeeps the rest of the image exactly as the digest pins it. The step can go once the base image ships the fix.
[0.1.4] - 2026-08-26
Fixed
- A bookmark whose domain a resolver sinkholes is no longer refused. Every ad blocker and DNS filter answers
0.0.0.0for a blocked name, and0.0.0.0/8classifies as loopback — so 0.1.3 turned "your resolver blocks this domain" into "refusing to point Linkwarden at a loopback address", which was both wrong and unhelpful. A resolved unspecified address is now passed over; it addresses nothing and nothing can be fetched from it.0.0.0.0written into the URL itself is still refused, because that one does address the host.
[0.1.3] - 2026-08-26
Security
The URLs handed to Linkwarden are now checked against the host they address.
create_link,update_linkandcreate_rss_subscriptionmake the Linkwarden server fetch a caller-supplied URL — through the headless-browser preserver or, for a feed, immediately — andget_link_contentreads the preserved text back out. Only the scheme was validated, sohttp://169.254.169.254/latest/meta-data/or a port on the Linkwarden host's own loopback was a perfectly acceptable bookmark, and its response came back to the caller. That is reachable from text inside a page the account has already saved. Loopback and link-local addresses are now refused, together with the metadata service's hostnames (metadata.google.internal,instance-dataand their siblings), which resolve only on the instance itself.Addresses are compared numerically instead of as strings, because
URLcanonicalises an IPv4-mapped IPv6 literal before any check sees it:http://[::ffff:169.254.169.254]/arrives as[::ffff:a9fe:a9fe]while every dual-stack client dials it as plain169.254.169.254. The IPv4-compatible, IPv4-translated and NAT64 spellings are unwrapped the same way, andlocalhost.with its root label is read aslocalhost.What is sent to Linkwarden is the parsed URL rather than the string that came in, so the address that was checked is the one that gets fetched.
http://ok.example.com\@127.0.0.1/feedhas the hostok.example.comfor a URL parser and127.0.0.1for a fetcher that splits at the@.A hostname that is not a literal address is resolved and its addresses are checked, so a DNS record pointing at
127.0.0.1or169.254.169.254no longer walks around the guard. A name that cannot be resolved here is still passed on: the Linkwarden server may sit in a different network with its own resolver.The metadata endpoints outside
169.254/16are refused as well:100.100.100.200(Alibaba Cloud) and192.0.0.192(Oracle's legacy endpoint) sit in carrier-grade NAT and IETF assignment space respectively, so no range check reaches them, but they are the same thing by purpose.The classifier strips an IPv6 scope id before deciding.
net.isIPaccepts::ffff:127.0.0.1%eth0, which made the dotted-quad fold miss its anchor and the address come out as routable. A URL can never carry one, but a resolver answer can.
Private LAN addresses (10/8, 172.16/12, 192.168/16, fc00::/7) stay allowed — bookmarking the router's web interface, a NAS or an intranet page is a normal thing to do with a self-hosted bookmark manager. SECURITY.md and the security guide now state what the check cannot cover, and say it plainly rather than in passing: redirects and whatever the headless browser loads from a page, the window between this lookup and Linkwarden's own, a name whose resolution is simply stalled past the timeout, represerve_link, containers sitting next to Linkwarden on a compose network — and above all the entries inside an RSS feed, which Linkwarden creates and preserves links for without checking their addresses at all before version 2.14.
Changed
update_linkcompares the new URL with the stored one in parsed form, and writes the parsed form back only when it really is a change. Re-sending the same URL spelled differently no longer counts as one, which would have asked for a confirmation and then destroyed every preserved copy for nothing — and because Linkwarden decides that by exact string equality, the re-spelled URL must not be written back either, or the archives would have gone silently.- The description of
create_rss_subscriptionno longer says that a feed "pointing at a private address is rejected right away" — that described Linkwarden's behaviour, not this server's. It now says which addresses this server refuses, and warns that the check covers the feed URL and not the entries inside it. - The description of
create_linkno longer tells the model that a private LAN address "is fine". This server accepts one, but Linkwarden 2.14 and later refuse to preserve it, so the bookmark is created and permanently has no archive to read. - The host classifier lives in
src/hosts.tsas a leaf module with no imports of its own, and the tool-facing check that throws moved tosrc/schema.ts. Having the classifier reachresult.tsputconfig.tsin an import cycle that held only because the functions in it happen to be hoisted.
0.1.2 - 2026-08-18
Fixed
- The architecture diagram no longer depends on the reader's operating system. It carried a
prefers-color-schemeblock, which resolves against the OS rather than the theme toggle of GitHub or npm — so dark-mode readers on a light OS got the light artwork on a dark page. The README now uses<picture>, which is resolved against the page, and the<img>that npm falls back to brings its own card instead of a media query. - The README embedded the diagram and the demo GIF with repo-relative paths, which npm does not resolve — neither image appeared on the package page. Both are absolute now.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand. docs/public/og.pngis generated at exactly 1280x640, GitHub's recommended size for a social preview, instead of being drawn by hand.- The demo recording is shown on the documentation home page as well, not only in the README, and is pinned to the content column so its width no longer depends on what the vhs tape happened to record.
0.1.1 - 2026-08-17
First release published by the automated pipeline, with npm provenance.
Added
- Multi-arch container image on GHCR (
ghcr.io/ni-c/linkwarden-mcp) for linux/amd64 and linux/arm64, built with an SBOM and build provenance. - Documentation site at https://linkwarden-mcp.ni-c.de, including a complete tool reference generated from the registered tools — CI fails when the committed reference no longer matches the code.
- Listed in the official MCP registry as
io.github.ni-c/linkwarden-mcp. CONTRIBUTING.mdand issue forms.
0.1.0 - 2026-08-17
Added
- Initial implementation: 28 tools for Linkwarden, split into 11 read tools that are always registered and 17 write tools that
LINKWARDEN_READ_ONLY=trueleaves out entirely. get_link_contentreads the article text Linkwarden extracted when it preserved a page, so a saved bookmark can be summarised or quoted without fetching the live site again. Long articles are sliced and every truncation names the follow-up call. Only the readable format is served — the screenshot, PDF and single-file HTML archives are binary or raw markup.search_linksdocuments Linkwarden's field-filter syntax (tag:,collection:,pinned:,before:,after:,!for negation) in its tool description, and maps readable sort names onto the integer enum the API expects. The description states that those filters need Meilisearch, and a query that uses one gets a note saying so — Linkwarden parses field filters only in its Meilisearch branch, and without it the whole query is matched as a single literal substring, sotag:newslooks for those nine characters and quietly finds nothing. Thecollection_id,tag_idandpinned_onlyarguments are applied by the database and work either way.- Sort orders and archived formats are exposed as names rather than as the integers Linkwarden uses on the wire.
Security
- URLs handed to Linkwarden must be
http://orhttps://. Zod's.url()only checks that the value parses, so it acceptsjavascript:,file:,data:andftp:too — and Linkwarden opens whatever it is given in its headless-browser preserver, whichget_link_contentthen reads back. Without the scheme check, a model acting on an instruction injected into a preserved page could have bookmarkedfile:///etc/passwdand read the result, entirely through valid tool calls. - The access token is removed from the environment before any branch of the configuration parser, not only on the fully-configured path. "URL missing or malformed" is exactly the state in which someone attaches an inspector or trips a crash reporter, and the server keeps running in it.
- A malformed
LINKWARDEN_URLis no longer echoed into the log. That branch fires precisely when the variable does not hold what was expected — a token pasted into the wrong variable would otherwise be printed verbatim. - Oversized results drop whole items instead of slicing the serialized JSON. Slicing produced a document cut off mid-string and, because
notesandnext_cursorare serialized last, discarded the pagination hint first — the one piece of information needed to recover from the truncation. - Per-field caps on titles, URLs and descriptions. The count limits bound how many records come back and the total budget bounds the whole result, but neither bounded a single record: one bookmark with a 200 kB description could crowd out everything else.
- Response bodies are read against an 8 MB ceiling, checked both from
content-lengthand while streaming. The result budgets only apply once a body is already in memory. bulk_update_linksreports ids and a count instead of forwarding Linkwarden's raw response, so it no longer bypasses the output allowlist.- Destructive tools require a server-generated, single-use confirmation token bound to the exact target, never a boolean argument. Set-valued operations bind the token to a sha256 fingerprint of the sorted id set, so a confirmation for
[1, 2]cannot execute[1, 2, 3], andbulk_update_linksandmerge_tagsadditionally bind it to the change itself — a confirmation for "add one tag" cannot be replayed as "replace all tags with nothing". - Two non-deletions are treated as destructive because they lose data just as irreversibly: publishing a collection (
update_collectionwithis_public=true) widens visibility to anyone with the URL, and changing a link's URL makes Linkwarden delete every preserved copy of the old page. - Confirmation prompts contain only counts, ids and flags. Titles, URLs, descriptions and collection names come from saved pages and from other users of the instance, and that text is read by a model.
- Output is an explicit allowlist rather than a pass-through of Linkwarden's Prisma rows. This keeps
textContent— the full article text of every link — out of list results, and drops the member names and avatars the collection routes include. - Partial updates read the current record and merge before writing. Linkwarden's update routes are replacements:
PUT /links/{id}applies tags withset: []first and writesname/descriptionasdata.name || "", andPUT /collections/{id}deletes every membership row before recreating it from the request body. An incomplete body would silently strip a link's tags or a collection's collaborators. - Mutation responses are screened instead of trusted. Several Linkwarden routes report failures with HTTP 200 and an error sentence in the body —
PUT /links/{id}/archiveanswers{"response":"Invalid URL."}that way — and a Next.js route with no branch for the HTTP method used falls through to a 200 with an empty body. Both are surfaced as errors. redirect: 'error'on every request. Linkwarden is commonly deployed behind a reverse proxy that redirects http to https, so a mistypedLINKWARDEN_URLwould otherwise replay the bearer token to the redirect target. A URL that already carries the/api/v1prefix is normalised rather than left to 308.- The token is deleted from
process.envafter the configuration is read, a URL containing credentials or a non-http scheme exits, and a token that does not look like a Linkwarden JWT produces a warning before the first 401. LINKWARDEN_INSECURE_TLSis a scoped undici dispatcher, neverNODE_TLS_REJECT_UNAUTHORIZED.- Preserved article text and all bookmark metadata are returned marked as untrusted content.
- Error bodies are truncated at 2000 characters and HTML error pages from proxies are dropped entirely.
- The container image deletes the npm and corepack that ship inside
node:24-alpine. The entrypoint is plainnode, so neither is used at runtime, and the packages they bundle were the only source of HIGH/CRITICAL findings in the image.
Per-release notes, with the same content, are on the releases page.