github getnora-io/nora v1.4.3

3 hours ago

[1.4.3] - 2026-10-27

Added

  • Connection limits in [server] — header_read_timeout (NORA_HEADER_READ_TIMEOUT, 30 s: the time a client has to send a request head, and for HTTP/2 to open its first stream), keep_alive_timeout (NORA_KEEP_ALIVE_TIMEOUT, 120 s: how long an idle connection, or an HTTP/2 connection without streams, waits before it is closed) and max_connections (NORA_MAX_CONNECTIONS, 4096: past it new connections wait in the listen backlog; keep it below the open-file limit). A keep_alive_timeout of 90 s or less starts with a warning: a reverse proxy keeping idle backend connections longer (traefik 90 s, nginx 60 s) would send requests into connections NORA is closing.
  • server.maintenance_stall_secs and background-task metrics — server.maintenance_stall_secs (NORA_MAINTENANCE_STALL_SECS, 300 s, at least 90) is how long the maintenance loop may go without a finished tick before /health answers 503. nora_task_panics_total{task} counts panics of long-lived background tasks and nora_maintenance_timeouts_total the maintenance ticks cut at their deadline, both exported from start at 0; deploy/prometheus-rules.yml gains a NoraTaskPanic alert. See MONITORING.md.

Changed

  • An OIDC provider without audience stops the start (Action may be needed) — a provider in [[auth.oidc.providers]] without audience used to start without a warning and accept a token whatever its aud (see Security). NORA now refuses to start with it, with an error naming the provider and exit status 78, which the packaged systemd units do not restart into, and refuses a SIGHUP reload that brings one in. Before upgrading, give every enabled provider the audience its CI jobs request (core.getIDToken('nora'), GitLab id_tokens: … aud:), or set any_audience = true where no nameable aud exists: it accepts any aud and warns at every start. A rollback to 1.4.2 with any_audience in the file warns about an unknown key, or does not start under NORA_CONFIG_STRICT=true.
  • nora import imports PyPI and NuGet packages as hosted (Action may be needed) — an import from Artifactory or Nexus skipped every PyPI and NuGet artifact and still marked the repository finished, so migrated names were answered by the public upstream (see Security). PyPI files and .nupkg files are now stored as an upload stores them and make their project or package hosted: its index, versions and dates are built from what NORA holds and no upstream is asked for the name; a store that cannot list those records answers 503 rather than hand the name to the upstream. Run the import again after upgrading: a PyPI or NuGet repository that 1.4.2 or older marked finished is walked again, with nothing to delete first, and the run after it transfers nothing. An imported PyPI project lists and serves only the files imported or uploaded to it: a file cached from an upstream under its name, or uploaded by 1.4.2 or older, is not served until it is uploaded again.
  • Token lifetimes are capped — every mint route (POST /api/tokens, POST /api/v1/admin/tokens, the web UI) now grants at most 3650 days, and a token minted by an OIDC identity lives no longer than its provider's max_token_lifetime_secs or what is left of its JWT, to the second, and a JWT with nothing left mints no token. Before, ttl_days was taken as asked (see Security). The answer of a mint carries expires_in_secs, the lifetime granted; expires_in_days stays, rounded down. Every mint leaves one token_mint audit entry with the actor, the owner, the role and the lifetime, never the token.
  • The password lockout counts per account, and a client is one address on a dual-stack listener — the lockout counted wrong passwords per client address, so behind one NAT a few wrong passwords for one account locked every account out, and any login from it cleared them all. It now counts a username at an address; an address is still held for every username after ten accounts' worth of wrong passwords, which a login does not clear. Only passwords count: an API token sent as the Basic password, or over Bearer, is still verified during a lockout, and a valid credential refused with 403 is not counted. On NORA_HOST=:: (the image default) an IPv4 peer arrived as ::ffff:a.b.c.d and missed trusted_proxies, so every client behind a loopback proxy shared one lockout and one rate-limit bucket; addresses are now IPv4 wherever they are read. X-Forwarded-For from a peer outside trusted_proxies is still ignored, now with one warning naming the peer.
  • /health answers 503 when the server can only be fixed by a restart (Action may be needed) — a request handler that panics now answers 500 and the server keeps serving (see Security), and every long-lived background task runs under a supervisor that logs background task panicked, counts it in nora_task_panics_total{task} and restarts the task after a backoff. /health answers 503 with failing_task when a task keeps panicking, or when the maintenance loop has finished no tick for server.maintenance_stall_secs (300 s); a tick waiting on a storage that does not answer is cut and fails /ready, not /health. A panic of the audit writer ends the process with exit status 70. Kubernetes restarts on the liveness probe; under Docker without Swarm, or systemd, a 503 restarts nothing by itself — add an autoheal watcher or a timer running nora healthcheck || systemctl restart nora.
  • npm tarballs come only from where the upstream's packument says — a tarball is fetched only for a version the upstream's packument lists, at the link it names; a version it does not list is 404 with no request for the tarball. A redirect on the upstream's origin is followed with its credential; one to another origin (GitHub Packages keeps files on a storage host) is followed without it, at most ten redirects for the whole chain within one npm.proxy_timeout, the eleventh is 502. A proxies entry bound to scopes answers their metadata only from its own origin: a redirect off it is 502. A version whose tarball lies on another host is left out of the documents NORA serves, with latest falling back to the highest release still listed; a tarball a GitLab instance-level endpoint keeps under its project, on the upstream's origin, is served through NORA.
  • Cache hits cost more CPU — the provenance and integrity checks this release adds run when cached metadata is served, and for npm the cost grows with the size of the document. Measured on cache hits of a release build: p95 of a large packument such as react (about 7 MB) went from about 10 ms to about 16 ms and express from about 2.0 ms to 3.3 ms; other packuments stay under 2 ms. On a mixed load, throughput, error rate and memory did not change; a small cached response such as a manifest, served at saturation, reaches about 9 % fewer requests per second.
  • Rolling back to 1.4.2 with the new [server] keys — 1.4.2 does not know header_read_timeout, keep_alive_timeout, max_connections and maintenance_stall_secs: it warns and ignores them, but under NORA_CONFIG_STRICT=true it refuses to start (exit status 78). Remove them from config.toml before a rollback, or set them through their environment variables (NORA_HEADER_READ_TIMEOUT and the others), which 1.4.2 does not read.

Fixed

  • A stored Docker blob is verified before its last byte goes out (#1169) — a blob served from storage was hashed as it streamed and its digest checked when the reader reached the end, but with Content-Length declared hyper stops reading once that many bytes are out, so the check never ran: a blob whose bytes changed on disk reached the client as a complete 200. Docker clients reject such a layer by its digest; curl and scripts did not. Stored blobs, and blobs served while a fill is in flight, now go through the verifying stream raw and Hugging Face files use: the last frame is held back until the digest is checked, so a mismatch ends the body one frame short and is logged.
  • A revoked token stays revoked — a revoke that landed while the last_used flush or the legacy-hash migration was rewriting the token file could be undone by that rewrite, and a verify that read the file just before the revoke could put the token back into the verify cache. Rewrites and removal of a token file now hold one lock, a rewrite skips a file the revoke removed, and a verify caches its result only if no revoke came in since it read the file.
  • Readiness tells a full, read-only or silent store from a slow one — /health and /ready no longer touch storage: they serve the verdict of the last maintenance tick (every 30 s), and a verdict older than 150 s counts as unreachable. On an object store the tick asks for the first page of a listing instead of listing the whole bucket, so a large bucket no longer keeps /ready at 503; the store's size is counted apart. On the local disk the write probe creates a new file each time and passes only when the create, write, fsync and removal all do, so a read-only remount or a full disk turns /ready to 503 within one tick instead of passing on a file left from an earlier probe. A store that does not answer by the tick's deadline turns /ready to 503 when the tick is cut.
  • The quarantine counts from NORA's first fetch without trusted dates — with trust_upstream_dates off, npm, pub, Cargo, Go, Conan, PyPI and NuGet handed the digest quarantine the age of cached metadata (when NORA last wrote a packument, listing or registration), so metadata cached a month ago let a file first fetched now pass as mature. The quarantine now counts from the first fetch, as documented; release age is unchanged. A NuGet version is dated by the cached registration that lists it, and is undated, so counted from NORA's first sight, when none does.
  • A proxied PyPI file must match the sha256 its index gives — a file whose bytes have another digest is refused with 502 and not cached; once cached, NORA would have listed it with its own digest and the next install would have accepted it. A file the index gives no digest for is served as before.
  • Smaller fixes — npm tarball links an upstream writes under the other scheme of its own host (an https registry behind a TLS terminator writing http://) are rewritten under NORA instead of sending the client past it; a RubyGems file name longer than any NORA serves is answered 400 before it is parsed, and parsing one of the longest servable length takes linear work; loading an htpasswd file warns once with the names of users whose hash can never match (a scheme other than bcrypt, an empty hash, a cost out of range), never the hashes.

Security

  • A package could be served under a name it did not own (GHSA-54qr-w49h-mgfq) — provenance was not always bound to the bytes and the origin a package was served from. An npm publish could store a tarball for a version it did not publish; a package NORA hosted could still be fetched from the upstream; a failure partway through an import or an upload could leave a file the upstream had served marked as the project's own; a Maven import skipped a coordinate the upstream cache already held, leaving those bytes in place of the source's; and an import from Artifactory or Nexus skipped every PyPI and NuGet package, so their names were answered by the public upstream. Each lets a foreign package be served under a trusted or internal name. A publish now stores only the tarballs of the versions it publishes, a hosted name is never asked of an upstream, a name is hosted before its first file and owns only what was stored, a record is written only after its file and digest, and an imported Maven artifact replaces any object under its key that is not pinned to the source's digest.
  • OIDC tokens were accepted without an audience check (GHSA-54qr-w49h-mgfq) — a provider configured without audience accepted a token whatever its aud claim, so a token the same issuer minted for another service (GitHub Actions has one issuer for every repository) authenticated to NORA, and nothing was logged. Such a provider now stops the start and is refused on reload; see Changed.
  • A storage read error produced wrong but successful answers (GHSA-54qr-w49h-mgfq) — when NORA could not read its own copy of a package's metadata or integrity data (an error, not a missing key), it could answer with a PyPI index link without its #sha256=, an npm version, tag or field left out, 403 for an internal package or 404 for one that exists — a client could install without verification or resolve an internal name elsewhere. These cases now answer 503 (500 on an integrity violation), the upstream is not asked and no digest is left out, across npm, PyPI, NuGet, Terraform, CPAN, Conan, RubyGems and the other formats.
  • Token mints were not held to a lifetime ceiling (GHSA-54qr-w49h-mgfq) — POST /api/tokens and POST /api/v1/admin/tokens passed ttl_days to the store unchecked: a large value made a token that never expires and a larger one wrapped around to a few hours; a token an OIDC identity minted could outlive its JWT, even one already expired within the clock leeway. Every mint route now takes its lifetime from one rule (see Changed) and is audited.
  • A release-age quarantine tightened by a reload was not applied (GHSA-54qr-w49h-mgfq) — for PyPI and NuGet a min_release_age rule a SIGHUP reload brought in did not ask for release dates, so a fresh version the reloaded policy should withhold was still served. The rule now applies from the reload on, and a reload that changes quarantine or quarantine_ttl, which apply after a restart, logs a warning naming them.
  • Bad passwords could make good logins pay bcrypt again (GHSA-54qr-w49h-mgfq) — repeated or concurrent checks of one Basic credential each ran bcrypt, and a flood of wrong passwords could keep an accepted one from being remembered; a route served without credentials, such as /metrics, ran bcrypt for a Basic header too. An unauthenticated client could use this to burn CPU and slow logins. Accepted and refused verdicts are now remembered apart, concurrent checks of one pair run bcrypt once, bcrypt runs off the async workers, and an open route never runs it.
  • Connections without a request were held open indefinitely (GHSA-54qr-w49h-mgfq) — an HTTP/1 connection that never finished its request head, and an HTTP/2 connection that opened no stream or went idle after one, were kept open with no deadline, so an unauthenticated client could exhaust connections. A connection now has server.header_read_timeout (30 s) for its request head; an HTTP/2 connection must open its first stream within it and is closed with GOAWAY after server.keep_alive_timeout (120 s) without one — keep-alive pings do not extend it — and server.max_connections bounds open connections. See Added.
  • An upstream URL rewrite could point a client at another host (GHSA-54qr-w49h-mgfq) — the npm, Ansible Galaxy and pub rewrite of the upstream's URLs into NORA's was a plain substring replace, so it also fired where the upstream URL was only a prefix of a longer host, path segment, port or userinfo (https://<upstream>.attacker.example/…); depending on the replacement, the result led to another host or to a mangled path on NORA. The rewrite now replaces only where the upstream's URL ends, in the plain and the JSON-escaped form, and an npm version whose tarball lies outside the upstream is left out of the documents NORA serves. NuGet was not affected.
  • A panic ended the whole process (GHSA-54qr-w49h-mgfq) — release builds used panic = "abort", so any panic reachable from a request or a background task stopped the server for every client. Release builds now unwind: a panic answers its request with 500 and the server keeps serving, and a long-lived background task is restarted (see Changed).
  • Upstream credentials were written to the quarantine ledger (GHSA-54qr-w49h-mgfq) — the quarantine ledger (quarantine.jsonl) recorded the upstream URL with its userinfo in clear (https://user:pass@host/…), readable by anyone who can read the ledger or its storage. It now records https://***@host/…, and a ledger an earlier release wrote is rewritten masked on the first start. Copies of the ledger taken before the upgrade still hold the credential; rotate it if they could be read.
  • An npm upstream's credential could follow a tarball redirect to another host (GHSA-54qr-w49h-mgfq) — a tarball was fetched with the upstream's credential (proxy_auth, or a scope's token) through a client that dropped it only on a hop to another host or port, so when the upstream redirected a tarball to a storage host that redirected again within itself, that second hop carried the credential to the storage host. The credential now goes only while every hop is on the origin of the tarball URL (see Changed). If an npm upstream with a credential redirected its tarballs to another host, rotate the credential.

Install

# x86_64
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora

# ARM64 (Apple Silicon, Graviton, Ampere)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-linux-arm64
chmod +x nora-linux-arm64
sudo mv nora-linux-arm64 /usr/local/bin/nora

Docker

docker pull getnora/nora:1.4.3
Variant Image Platforms
Alpine (default) getnora/nora:1.4.3 amd64, arm64
RED OS getnora/nora:1.4.3-redos amd64
Astra Linux SE getnora/nora:1.4.3-astra amd64
GHCR ghcr.io/getnora-io/nora:1.4.3 amd64, arm64

DEB / RPM

# Debian / Ubuntu / Astra Linux (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-amd64.deb
sudo dpkg -i nora-amd64.deb

# RHEL / Fedora / RED OS (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.3/nora-amd64.rpm
sudo rpm -i nora-amd64.rpm

Changelog

See CHANGELOG.md

Don't miss a new nora release

NewReleases is sending notifications on new releases.