Security
-
mcp-approval 0.8.2. 0.3.1 already shipped the single-use sealed dialog
answer of 0.8.1, on npm and in the Docker image, which is built from the
lockfile. 0.8.2 addsorderedResourceKey, taken up below. -
Approval keys bound to positions.
mark_all_as_readkeys its confirmation
on the pair (stream, cut-off).setResourceKeysorts its parts before
fingerprinting — set semantics — so a token issued for stream A and cut-off B
would also have confirmed the pair the other way round. FreshRSS issues no
stream id that is all digits, so the swap could not be expressed through the
tool, but the shape is a tuple and the key now comes fromorderedResourceKey
in mcp-approval 0.8.2, which fingerprints each part at its position.
mark_articles,unsubscribe_feed,delete_category_or_labeland
import_opmlkey sets or single ids and stay onsetResourceKey. -
A refused login is not retried for ten seconds. Every tool call that
needed a token was a freshClientLogin, and FreshRSS writes every refused
one into its log asPassword API mismatch for user ….get_user_infois
annotated read-only, idempotent and cheap, and its 401 answer says "check
the password" — which is what a model retries. A wrong password in the
configuration was a burst of failed logins in the instance's log, and a
reverse proxy's rate limit or a fail2ban jail keyed on that line locks the
operator's own address out. A refused login (any status) is now remembered
for ten seconds and repeated from memory, with a note saying so and when the
next attempt is possible. The one retry after a 401 on a request is
unchanged; its refusal is what starts the cooldown. -
The publish job installs without running install hooks.
release.yml's
publish job holdsid-token: writefor npm Trusted Publishing and ran a
plainnpm ci, so every dependency's install hook ran with the OIDC token
reachable from the job environment. It runsnpm ci --ignore-scriptslike
the audit job and the Dockerfile already did — nothing in the tree declares
a hook — andgh release createchecks the tag exists with--verify-tag. -
Tokens from the instance have a shape. The
Auth=line of the login
answer went into theAuthorizationheader as it came; a carriage return or
a NUL in it made undici throwHeaders.append: "GoogleLogin auth=…" is an invalid header value— the instance's string, unlabelled and unbounded,
into the model context. The write token rode in every form the same way.
Both must be visible ASCII of at most 1024 characters; anything else is
reported as "without a usable Auth token" / "not a write token". -
The startup line does not print a value that is not a URL. A
FRESHRSS_URLthat failed to parse was printed (redacted for userinfo), and
one with an unknown scheme was printed asgot ${scheme}— a
fifty-six-character key with a colon behind it is a valid URL whose scheme
is the key.FRESHRSS_API_PASSWORDsits one line below this variable in
every compose file, and a password pasted into the wrong line is exactly a
value that does not parse. Only a value with://is quoted, redacted and
cut to 120 characters; anything else is described by its length. -
mark_articlesvalidates the ids before it asks. The dialog was raised
and the key built on the ids as written, andassertArticleIdran
afterwards — so the person was asked about a list that might not run, and
an approval could be spent on a call that then failed. Validation now comes
first and the key is built from the validated ids;articleIdsin the
answer is the validated form. -
Control characters and lone surrogates in three more places.
get_unread_countshanded feed titles on as they came, unlikelist_feeds;
export_opmlpassed the document through with whatever was in it (XML 1.0
forbids those characters, so a well-formed export is unchanged); and
�in an article — or a\ud800escape in the instance's JSON —
produced a lone surrogate, whichString.fromCodePointdoes not refuse and
every UTF-8 encoder on the client side does. Every string that leaves this
server is well-formed now (toWellFormed), and a surrogate reference
decodes to U+FFFD.
Fixed
-
What the instance sends is read at a boundary, not through a cast.
Every response was a TypeScript cast, and one value of the wrong shape took
the whole listing down — asOutput validation errorfrom the SDK's check
of the output schema, or as aTypeError.feed/1e300or a twenty-digit
feed id (.int()refuses both);1e999inunread-count, which
JSON.parsehands over asInfinity;publishedpast ±8.64e12 seconds,
whichtoISOStringthrows on; a number where a title, an author, a URL or
an id belongs;itemsthat is not a list; a body that isnull. Objects,
arrays, strings, finite numbers and safe integers are now decided at the
boundary (src/boundary.ts), a field of the wrong shape is omitted and an
element of the wrong shape is skipped, feed ids are bounded to fifteen
digits, and display strings are cut at 2000 characters (URLs at 8192) so
one oversized title cannot cost the listing. A property test feeds every
read tool arbitrary JSON and shaped envelopes with arbitrary leaves through
the whole server and asserts on the absence of those errors; the harness
lists the tools once per connection, so every success path in the suite is
checked against the listed schema. -
The status is decided before the body is read. A 401 or a 5xx with a
body past the 64 MiB ceiling answered "more than 67108864 bytes" — the
size instead of the status, no hint about the credentials, and the one
retry a 401 earns never ran. An error body is now read under its own
64 KiB ceiling, which cuts rather than refuses. -
&constructor;in an article decoded to a function. The named-entity
table was an object literal, so&constructor;looked up
Object.prototype.constructorand the replace callback wrote
function Object() { [native code] }into the text. The table is aMap. -
import_opmlmeasures the document in the bytes FreshRSS reads. The
ceiling was 900 000 characters on the document as given; FreshRSS reads
1 048 576 bytes and drops the rest, and the document sent is the
rewritten one — canonical URLs and XML escaping make it longer. A document
that would be cut on the FreshRSS side is refused before the dialog, with
the size it would be sent at. -
import_opmlquotes FreshRSS's answer like every other tool — through
upstreamText, cut and labelled, instead of the raw first 200 characters. -
Caller strings have ceilings at the schema.
since,untiland
older_than(64 —Date.parsewalks whatever it is given),continuation
(256), article ids (64), category and label names (200), a feed URL (8192)
and a title (1000). -
get_user_inforeads the account strings as strings, cleaned and cut
to 200 characters; a number where the user name belongs no longer fails the
schema. -
Trailing slashes in
FRESHRSS_URLare removed with a counted walk
rather than/\/+$/, which is quadratic in the length of the run.
Changed
- The runtime image no longer carries yarn, corepack or
package-lock.json;
nothing in it runs them. list_article_idscarriesnotes, for the case where FreshRSS sends a
continuation value larger than this server accepts.