Security
- Feed error messages no longer expose server network details. When a feed
fails to fetch, the reason shown in the sidebar tooltip is read by every
subscriber of that feed. It was the raw Go error, which embeds whatever the
connection resolved to — e.g.dial tcp 10.0.0.5:443: connect: connection refused, handing any user a piece of your internal network map. Subscribers
now see a plain summary ("could not connect to the server", "the server
responded 404", "the server's TLS certificate could not be verified"), while
the complete error is still written to the server log for operators. The
mapping is fail-closed: an unrecognised error reports a generic message
rather than falling back to the raw text. - Bumped
golang.org/x/textto 0.40.0, picking up the fix forGO-2026-5970
(an infinite loop on invalid input). Ember reached the affected code when
draining an Ollama model-pull response. - Repeated failed logins are now throttled per account, not just per IP.
Ember already rate-limited the login endpoint per source address, which does
nothing against a credential-stuffing run spread across many addresses. Each
username now also gets an escalating backoff: the first five consecutive
failures are free, after which every further attempt has to wait — 1s, 2s,
4s, doubling up to a one-minute ceiling — returned as429with a
Retry-Afterheader. A successful sign-in clears it immediately. This is
deliberately a delay and not a lockout: a hard lock would let anyone who
knows your username keep you out of your own reader. Failed attempts past the
free allowance are now logged with the username and client IP so an attack in
progress is visible. - A passkey whose signature counter stops advancing is now rejected. That
is the WebAuthn spec's signal of a cloned authenticator or a replayed
assertion; Ember previously recorded it and signed the user in anyway.
Authenticators that don't implement a counter at all (most phones and
laptops) are unaffected, as the spec requires. - Hardened the login endpoint against memory exhaustion. Every attempt runs
a 64 MiB argon2id derivation — including attempts for usernames that don't
exist — and nothing bounded how many could run at once, so enough concurrent
attempts could push the process into an out-of-memory kill. Derivations are
now capped at four in flight; the rest queue. - The login rate-limiter's internal table is now bounded, so traffic spread
across a large address range can't grow it without limit. - Closed two SSRF bypasses in the private-address block.
http://[::]/and
IPv4-compatible addresses likehttp://[::127.0.0.1]/slipped past the guard
that stops Ember fetching internal hosts, because Go only normalizes the
IPv4-mapped form (::ffff:a.b.c.d) when comparing against IPv4 ranges — so
the loopback check never matched, while the connection itself reached
localhost. The whole::/96range is now blocked (nothing routable lives
there). Affected every outbound fetch: adding a feed, discovery, the poller,
and the image proxy. - Web Push notifications had a data race that could corrupt payloads. A
single encoded notification was shared across the goroutines fanning out to
each of your devices, and the push library writes into the spare capacity of
the buffer it's handed — so concurrent sends could scribble over each other.
Each send now gets its own copy. - Directory listings are no longer served for static asset paths. Requesting
/assets/returned an index of every built file, and because that path also
carries a year-longimmutablecache header, the listing was cached as if it
were a content-hashed asset. Directory requests now return 404; single-page-app
routes are unaffected. - Digest emails now use an unpredictable MIME boundary. The boundary was
guessable, and article titles are written into the plain-text part unescaped,
so a crafted title could close the part early and forge additional MIME
sections in the message. Boundary generation now fails the send outright
rather than falling back to a fixed value.
Changed
- The interface stays responsive while feeds are being fetched. Ember opens
a single SQLite connection because the database allows only one writer, which
meant every read — the article list, sidebar counts, search — had to queue
behind whatever the background poller happened to be writing. Reads now go
through a second, read-only connection pool that runs alongside the writer, so
a refresh in progress no longer stalls the UI. Worst-case read latency during
a feed fetch drops from ~166ms to ~23ms, roughly 1.45x faster on a typical
sidebar load. Note for operators: this raises Ember's SQLite page-cache
budget from 64 MiB to about 128 MiB (the read pool is deliberately capped at
four connections of 16 MiB rather than inheriting the writer's 64 MiB each).
If the read pool can't be opened for any reason, Ember logs a warning and
serves reads from the write connection exactly as before. - Bumped Go runtime dependencies:
github.com/mmcdole/gofeed1.3.0 → 1.4.0
(which moves togoxpp/v2and drops five transitive dependencies),
github.com/pressly/goose/v33.27.2 → 3.27.3,golang.org/x/crypto
0.53.0 → 0.54.0,golang.org/x/net0.56.0 → 0.57.0, and
modernc.org/sqlite1.53.0 → 1.55.0. - Bumped SPA build/dev tooling: Svelte 5.56.4 → 5.56.8, Vite 8.1.3 → 8.2.0,
@sveltejs/vite-plugin-svelte7.1.4 → 7.2.0, svelte-check 4.7.1 → 4.7.4,
@testing-library/jest-dom6 → 7,@types/node26.1.0 → 26.1.2, jsdom
29 → 30.0.1, and@playwright/test1.61.1 → 1.62.1. These are dev-only and are
not bundled into the Ember binary. TypeScript is deliberately held at 6.x:
svelte-check 4.7.4 supports TypeScript 7 only with both TypeScript 6 and 7
installed side by side and an extra--tsgoflag, which is a build-tooling
migration rather than a version bump.
Fixed
- Scheduled OPML exports written before noon UTC are no longer lost. The
export filename was built from a Go time layout that had.opmlfolded into
it, and Go reads thepmin that extension as the AM/PM marker — so every
export produced between 00:00 and 11:59 UTC was written as.oamlinstead.
Those files landed on disk but never matched the.opmlfilter behind the
admin export list, leaving them invisible in Settings → Database and
impossible to download or delete from the UI. Affects 0.9.4 and 0.9.5;
existing.oamlfiles can be renamed to.opmlto bring them back into the
list. - Articles no longer stay hidden waiting for their AI summary. When
summaries are enabled, a new article was held back until the model had
finished with it — with no time limit. On CPU-only inference that meant
minutes of an apparently empty reader, and an article dropped from a full
summary queue stayed invisible until Ember was restarted. Articles now appear
once they've waited longer than a grace window (2 minutes by default), with
the summary filling in when it's ready. Tune it in Settings → Language
model → Article visibility or withEMBER_SUMMARY_GRACE_SECONDS;0shows
articles as soon as they're fetched. (#162) - Articles dropped from a busy summary queue are picked back up. The queue
is bounded, and when it was full the article was silently skipped — it then
had no summary, so the gate above hid it, and the only thing that retried was
a sweep that ran once at startup. That sweep now runs every poll cycle. - Turning on the daily digest works again. Saving from Settings → Digest
always failed with a generic "invalid request body" error, so the feature
could not be enabled at all from the UI. Ember was rejecting a request its
own interface had built: the settings it sends back on save included two
read-only fields (user_id,last_sent_at) that the save endpoint didn't
accept. The endpoint now accepts and ignores them — so fetching your digest
settings, changing one, and saving them back works from any client — and the
interface no longer sends them in the first place. Which account the settings
belong to is still taken from your session, never from the request, so the
accepted field can't be used to change someone else's digest.
Thanks to @Zaptorg for the report and the root-cause analysis (#161). - The All Unread badge could drift below the real count while you read.
Marking an article read from Starred, Read Later, Shared, or a board
decremented the All Unread badge even when that article was older than the
unread window — so the badge subtracted something it had never counted, and
showed fewer unread articles than the All Unread list actually contained.
Those four views are deliberately not windowed (they show saved items of any
age), which is why only they were affected. The badge self-corrected on the
next server refresh, but that refresh is debounced and restarts on every
read, so while you were scrolling through a list it stayed wrong. - Newsletter mail addressed to several of your inboxes was being dropped.
The inbound SMTP server overwrote the recipient on eachRCPT TOinstead of
collecting them, so when one message was addressed to multiple Ember inboxes
only the last one received it. Nothing bounced — the sender saw a normal
success — so the loss was silent. - Feeds that return unparseable content no longer get retried forever. Only
fetch failures widened the retry interval; a feed that fetched fine but
failed to parse kept being re-requested at the floor interval indefinitely
(~48 requests a day at the default, aimed at someone else's origin). Parse
failures now back off on the same curve as fetch failures. - OPML import now reports how many subscriptions it actually added. It
previously counted every feed in the file, so re-importing the same OPML
claimed to have added everything again when it had added nothing. - Backup and OPML-export retention only touches files Ember created. The
prune and delete paths matched any.db/.opmlfile in the configured
directory. Since both directories are admin-settable, pointing one at a
directory holding the liveember.dbcould have deleted the database in use.
They now match only Ember's ownember-<timestamp>.db/.opmlnaming.
Added
-
Turn AI summaries off for a single feed. Feeds you skim don't need a
summary, and on CPU-only inference they cost minutes of work each. The feed's
⋯ menu in the sidebar gains Don't summarize — the feed keeps updating
normally, it just stops being sent to the model, and its existing summary
cards disappear from your view. Switching it back on re-queues the articles
that were skipped while it was off, so you don't have to wait for the feed to
publish something new. The entry only appears while AI summaries are enabled
on the server. It's a per-account choice: because a summary is stored once and
shared by everyone subscribed to a feed, Ember only skips the inference when
every subscriber has opted out — otherwise the work still happens and your
copy simply arrives without the summary card. The "Summarizing N articles"
indicator no longer counts work for feeds you've opted out of. -
Optional device verification for passkey sign-in. A new Require device
verification toggle in Settings → Passkeys (orEMBER_PASSKEY_REQUIRE_UV=1)
makes passkey sign-in demand a PIN, fingerprint, or face scan, so a passkey
becomes two factors rather than possession alone. Off by default, because a
passkey enrolled on a security key with no PIN configured would stop working
and need re-registering — turn it on once you know every registered passkey
can verify. Phones and laptops (Touch ID, Windows Hello) always verify, so
they're unaffected either way.
What's Changed
- chore(deps): apply the grouped Go + web bumps, holding TypeScript at 6.x by @brandonhon in #169
- web,api: stop the All Unread badge drifting below its list by @brandonhon in #167
- auth,api,store: throttle login brute force and harden passkey assertions by @brandonhon in #166
- refactor(internal): consolidate duplication, fix 8 latent bugs, raise coverage by @brandonhon in #168
- chore(deps-dev): bump postcss from 8.5.15 to 8.5.23 in /docs by @brandonhon in #178
- chore(deps): catch up Go, web, and GitHub Actions dependencies by @brandonhon in #181
- ci(deps): bump pinned GitHub Actions, keeping the codeql trio in lockstep by @brandonhon in #187
- store,db: serve heavy read-only queries from a separate WAL read pool by @brandonhon in #188
- docs(changelog): record the read/write connection split by @brandonhon in #189
- release: v0.9.6 by @brandonhon in #194
Full Changelog: v0.9.5...v0.9.6