Skip to content

4.0.0 release prep - #17

Merged
taplin merged 33 commits into
mainfrom
release-prep
Oct 14, 2026
Merged

taplin merged 33 commits into
mainfrom
release-prep

Conversation

@taplin

@taplin taplin commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Release prep for Perfect-Session 4.0.0, the first release since 3.1.5 (2018) and the last package in release wave 3. It pins Perfect-MySQL 4.0.0, Perfect-PostgreSQL 5.0.0, Perfect-Redis 4.0.0, Perfect-SQLite 6.0.0 and Perfect-MongoDB 4.0.3, and adapts the Redis backend to Redis 4.0.0's async actor API.

Two adversarial review rounds; the second came back clean, with nothing high or medium.

Security fixes

  • Session tokens. On Linux, Foundation's UUID() returns an all-zero UUID once the process runs out of file descriptors, so every new session got the same token (reproduced). Tokens are now 32 bytes from SystemRandomNumberGenerator, 64 hex characters.

  • AuthFilter bypass. Under Perfect-NIO's case-insensitive router, /Admin/users reached a route protected as /admin. The new requiresAuthentication(_:):

    • matches by path segment, so /admin covers itself and everything below it but not /administrator;
    • ignores case by default;
    • supports * only as a whole final segment;
    • requires authentication for any path containing .., %, ; or \, or when a rule is malformed.

    shouldWeAccept is deprecated and now calls the new function.

  • Redis keys. The cookie token was used as the bare Redis key, so a client could name any key in the database. Keys now carry a perfect-session: prefix.

  • Sessions coming back after logout. Saving after logout recreated the session on Redis and the in-memory driver. Redis now saves with SET … XX.

  • Expired sessions. Every backend's resume now refuses them; before, all five persistent backends accepted them. There's also an optional absoluteTimeout, which clean(), the MongoDB TTL index and the Redis TTLs enforce.

  • Session fixation. New regenerate(). It keeps the session's creation time, so it can't be used to extend the absolute timeout.

  • CSRF. A value that can't be stored as JSON (a Date, URL or NaN) used to crash the process, and later wiped all session data including the CSRF token. Now only that key is dropped, and its name is logged. resume makes sure a CSRF token exists. New constant-time CSRFSecurity.tokenMatches. isValid now compares scheme and default ports, ignores case, and rejects a null origin.

  • CORS. https://*.example.com accepted every https origin.

  • Logs. Full tokens appeared in error logs; logs now show a 12-hex SHA-256 prefix instead.

  • Cookies. New SessionCookie builder. It refuses values that would inject cookie attributes, and recognises the __Host-/__Secure- prefixes in any case.

Correctness

  • save renews the idle timeout on every backend. Before, only Redis did, contrary to the docs and to 3.x.
  • Fixed traps:
    • an idle of Int.max, both the overflow and the Redis TTL, which is now clamped;
    • a negative MySQL port.
  • New SQL tables use 64-bit time columns; the README has an ALTER TABLE for existing ones.
  • PostgreSQL connection strings now encode reserved characters in passwords.
  • Redis commandTimeout defaults to 5 s (Tim's decision).
  • Config settings that have no effect in 4.0 are deprecated with a message saying so.

CI and docs

  • CI covers Linux 6.2 and 6.4 × debug/release against MySQL, PostgreSQL, Redis and MongoDB services (libmongoc 2.5.5 built from source and cached), and macOS on Xcode 26.0.1 and 26.6. It runs with SESSION_REQUIRE_LIVE, TZ set and warnings as errors.
  • The README has security notes and a Perfect-NIO middleware example with CSRF and authentication checks; both examples compile against NIO 0.1.0. It also covers migration from 3.x and from main.

Testing

160 tests, live against MySQL 8.4, PostgreSQL 17, Redis 7, MongoDB 8 and SQLite, with TZ=America/Denver and warnings as errors.

Configuration Result
Xcode 27 (Swift 6.4), debug and release 160/160
Xcode 26.3 (Swift 6.2.4), debug and release 160/160
Linux amd64, Swift 6.2 and 6.4, debug and release 160/160
Thread Sanitizer clean; exit tests skipped there by design
Guard Malloc all 6 bundles pass
Scratch consumer, from: "4.0.0" builds and runs a Perfect-NIO 0.1.0 session flow

Known limitations (in the notes)

  • MySQL's TEXT column holds 64 KiB of session data, and larger saves are lost.
  • AuthFilter ignores case using full Unicode rules. With Perfect-NIO 0.1.0 this can't be exploited, because NIO refuses non-ASCII request targets.
  • regenerate can leave the old token valid if destroying it fails.
  • The MySQL and PostgreSQL connectors have no TLS options.
  • A NUL character in a user ID or user agent fails on PostgreSQL and is cut off on SQLite.

After merge: tag 4.0.0.

🤖 Generated with Claude Code

taplin and others added 30 commits October 14, 2026 08:53
…MongoDB 4.0.3

Replace the branch: "main" sibling dependencies with from: the released
versions, and raise Perfect-MongoDB from 4.0.2 to 4.0.3. swift package
resolve picks CRUD 3.0.1, MySQL 4.0.0, PostgreSQL 5.0.0, Redis 4.0.0,
SQLite 6.0.0 and MongoDB 4.0.3. Package.resolved stays untracked
(.gitignore).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each backend's live suite now uses .enabled(if:) instead of returning early,
so a disabled suite reports as skipped rather than as passing tests. Each
has a serverIsReachable test, so an unreachable server fails the run, and a
top-level test fails when SESSION_REQUIRE_LIVE=1 but the backend's switch
isn't set (CI sets it). MySQL and PostgreSQL read MYSQL_PORT and PG_PORT;
MySQL's live tests require MYSQL_PORT so they never fall back to a local
server on 3306. MongoDB's two SessionDocument tests, which need no server,
move to their own suite so they always run.

Verified: unset REDIS_TESTS with SESSION_REQUIRE_LIVE=1 fails; pointing each
backend at a closed port fails its serverIsReachable test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PerfectSession.tojson() and the Redis driver's encoder called
JSONSerialization.data(withJSONObject:) under try?, but on Darwin it raises
an Objective-C NSInvalidArgumentException for a Date, URL, NaN, infinity or
a non-string dictionary key, which Swift can't catch: storing a Date in
session.data and saving terminated the server. Check isValidJSONObject
first, so tojson() returns "{}" as its fallback already intended, and the
Redis driver saves empty data. Document which values data can hold.

Reproduced: the new core test and the Redis live test both aborted with
signal 6 (NSInvalidArgumentException) before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CORSSecurity.isAcceptable accepted an origin when it started with the text
before a pattern's * OR ended with the text after it, so the pattern
"https://*.example.com" accepted every https origin, and
"http://localhost:*" every http origin. Match the whole pattern instead
(each * matches any run of characters), and compare patterns ignoring case
as origins already were.

Reproduced: the new test failed with 5 issues before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three fixes in RedisSessionDriver, adapted to Perfect-Redis 4.0.0:

- Keys were the bare token, and tokens come from clients' cookies. A cookie
  naming one of the application's own keys resumed it as a session (a JSON
  value with a "userid" became that user's session), and logging out with
  it deleted the key. Keys are now RedisSessionConnector.keyPrefix + token
  (default "perfect-session:"). Sessions stored before this aren't found.
- save() used a plain SET, so a save racing a logout (destroy) stored the
  session again. It now uses SET ... XX and logs at debug when nothing
  matched, as the MongoDB driver does. create() uses NX.
- The TTL was SessionConfig.idle, not the session's own idle, and an idle of
  0 or less stored the key with no TTL at all, so it never expired. The TTL
  is now session.idle; save() deletes a session whose idle is 0 or less, and
  create() doesn't store one.

Also: RedisSessionConnector.commandTimeout passes through to
RedisClientIdentifier (default nil, Perfect-Redis's default); every call
closes its connection on the error path too; a failed resume is logged.

Reproduced: saveAfterDestroyDoesNotResurrect, expiryFollowsSessionIdle,
nonPositiveIdleDoesNotMakeASessionImmortal, tokenCannotReachOtherKeys and
keysAreNamespaced failed (7 issues) before the change. Tokens with spaces,
colons, CR/LF, NUL or RESP framing were already harmless (bulk strings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MemorySessionDriver.save() inserted unconditionally, so a save racing a
logout brought the session back, and saving a PerfectSession built around
a client-chosen token stored it. It now only updates a stored session, like
the SQL drivers' UPDATE, MongoDB's upsert: false and Redis's SET XX. Live
tests confirm MySQL, PostgreSQL and SQLite already behaved this way.

Reproduced: memorySaveOnlyUpdatesStoredSessions failed with 2 issues before
the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PerfectSession.isValid(), MemorySessionDriver.clean() and the MongoDB
SessionDocument computed updated + idle with a trapping add, so an idle of
Int.max (a natural "never expire") crashed the process. Add
PerfectSession.expires, the sum clamped instead of overflowing, and use it.
The MongoDB driver also clamps expiresAt to years 0001-9999: Perfect-MongoDB
4.0.3's BSONEncoder traps converting a Date beyond Int64 milliseconds to a
BSON date (BSONCodable.swift:279), which a large idle reached even without
the overflow.

Reproduced: hugeIdleDoesNotTrap trapped (signal 5) in both the core and
MongoDB test bundles before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setup() created created/updated/idle as 32-bit int (MySQL) and int4
(PostgreSQL). Timestamps stop in 2038, and an idle time past Int32.max
(about 68 years, or Int.max for "never") made the INSERT/UPDATE fail, so the
session silently wasn't stored or saved. New tables use bigint/int8; the MySQL
driver reads either width. clean() compared updated + idle in integer
arithmetic, which errors on overflow, so one session with a huge idle made
every clean() fail; it now sums as DECIMAL (MySQL) or numeric (PostgreSQL).

Tables created by earlier versions keep their 32-bit columns
(CREATE TABLE IF NOT EXISTS); the README says how to ALTER them.

Reproduced: timesPast32BitsRoundTrip failed on MySQL and PostgreSQL before
the schema change (idle came back wrong), and its clean() check failed on
both with the old sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostgreSQLSessionConnector.connectionString() encoded with .urlQueryAllowed,
which leaves /, @, :, ? and = unencoded, so a password (or user name or
database) containing one of them produced a URI libpq parsed differently:
the driver couldn't connect. Encode everything but RFC 3986's unreserved
characters; the host also keeps [ ] : for IPv6 literals. Add
connectionString(username:password:host:port:database:) so the encoding can
be tested without touching the shared settings.

Reproduced: passwordWithReservedCharactersConnects (trust auth, password
"p@ss/w:rd?x#1") failed to connect and the string test failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… timeout

resume() returned sessions whose idle time had run out (until clean() or,
for MongoDB, the TTL monitor deleted them), leaving the check to callers'
PerfectSession.isValid(). Middleware that forgot it kept expired sessions
alive. Each driver's resume() now throws InvalidSessionError for an expired
session, so idle expiry is enforced on the server side.

Add PerfectSession.isExpired(at:absoluteTimeout:) and
SessionConfig.absoluteTimeout (seconds from creation; default 0, no limit),
checked by isExpired(), isValid() and every resume(). There was no way to
cap a session's lifetime while it stayed active.

The clean() tests now check the stored rows/documents directly, since
resume() no longer shows whether clean() deleted anything.

Reproduced: resumeRejectsExpired failed on the memory, MySQL, PostgreSQL,
SQLite and MongoDB drivers before the change (Redis already expires keys
by TTL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens were UUID().uuidString. On Linux (Swift 6.2 and 6.4), Foundation's
UUID() fills its bytes by reading /dev/urandom and ignores a failed open():
in a process that has run out of file descriptors (EMFILE, a real state for
a loaded server) every UUID is 00000000-0000-4000-8000-000000000000, so
every new session, and its CSRF token, got the same value.

Add PerfectSession.newToken(): 32 bytes from SystemRandomNumberGenerator
(getrandom(2) on Linux, arc4random_buf(3) on Darwin; it never returns
unfilled bytes) as 64 lowercase hex digits. Every driver and setCSRF() use
it. Tokens are now 64 characters instead of 36; every backend's token
column/key holds them.

Reproduced in swift:6.2-noble and swift:6.4-noble containers (ulimit -n 256,
then UUID() twice: identical all-zero UUIDs). The new exit test
tokensStayRandomWithoutFileDescriptors, which starves a child process of
descriptors, failed on Linux 6.4 before the change and passes after; on
macOS UUID() was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing let an application move a session to a new token, so a token an
attacker planted or saw before sign-in stayed valid after it. Add
regenerate(_:) to SessionDriver, with a default implementation for every
driver: create a session (new token, new CSRF token), copy the user, data
and idle time into it, save it, and destroy the old one. Applications call
it on sign-in and privilege changes and send the client the new token.

Client-supplied tokens for sessions that don't exist were already refused
by every driver's resume(); save() no longer stores them either (the
memory-driver fix), so a planted token can't be adopted.

Tests: memoryRegenerate and a live regenerateMovesSessionToNewToken for
each backend (MySQL, PostgreSQL, SQLite, Redis, MongoDB). There was no API
to test before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ings

SessionConfig had the cookie settings (name, Domain, Path, Secure,
HttpOnly, SameSite; HttpOnly and SameSite=Strict by default), but nothing in
the package applied them: each middleware built the header by hand. Add
SessionCookie.setCookie(token:) and expiredCookie(). They add Secure
whenever SameSite=None or the name has a __Secure-/__Host- prefix, and
return nil instead of a header a browser would misread when the name,
token, path or domain contains ; , CR, LF, whitespace or quotes, or a
__Host- cookie has a Domain or a path other than /.

New feature, so its tests (SessionCookieTests) are new rather than a
before/after reproduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verification only; no code change. SQL text in tokens, user IDs and data
keys/values ('...' OR, backtick and double-quote DROP TABLE, %, backslash)
is bound as parameters on MySQL, PostgreSQL and SQLite and round-trips as
text. On MongoDB, $-prefixed and dotted keys at any depth round-trip as
data, and operator-like tokens match nothing (the _id filter is a typed
string).

Not covered: a NUL in a user ID or user agent. PostgreSQL text can't hold
it (the write fails) and SQLite's text binding stops at it; documented as
a limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add concurrentSaveAndResume for every driver (40 concurrent saves and
resumes of one session: last writer wins, the CSRF token survives).

ThreadSanitizer reported a data race in the core tests: sessionIsValidUALock
and sessionIsValidIPLock wrote SessionConfig.userAgentLock/IPAddressLock
while tests in parallel called isValid(), which reads them. The CSRF and
CORS tests had the same pattern. So the checks can be tested (and called)
without the globals, PerfectSession.isValid gains ipAddressLock: and
userAgentLock:, CSRFSecurity.isValid acceptableHostnames:, and
CORSSecurity.isAcceptable config:, each defaulting to SessionConfig's
value; existing calls are unchanged. The tests now pass explicit values.

The fd-starvation token test is skipped under ThreadSanitizer, whose
runtime aborts a process with no descriptors left.

swift test --sanitize=thread (macOS, Xcode 27, all five live backends):
three runs, no reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MySQLSessionDriver, PostgreSQLSessionDriver and SQLiteSessionDriver are
final classes with no stored properties, so the compiler checks their
Sendable conformance; @unchecked only hid that. Document why
PerfectSession (data is [String: Any]) and MemorySessionDriver (NSLock)
keep @unchecked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MySQL driver converted MySQLSessionConnector.port with UInt32(_:), which
traps on a negative value. Use UInt32(exactly:) and refuse ports outside
1...65535 (port 0 would silently mean 3306): the connection stays unopened,
so create() stores nothing and resume() throws InvalidSessionError.

Also extend the SQL text probes with non-ASCII (emoji, accented) values,
which round-trip on all three SQL backends.

Reproduced: invalidPortDoesNotTrap crashed the MySQL test process ("Negative
value is not representable") before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uired

Bring the workflow up to the wave-3 standard: permissions contents: read,
30-minute timeouts, TZ=America/Denver, and every build with
-warnings-as-errors (release with -enable-testing for @testable).

Linux: swift:6.2-noble and swift:6.4-noble (was 6.4-resolute only), debug
and release, against MongoDB 8, PostgreSQL 17, MySQL 8.4 and Redis 7
services. Ubuntu 24.04's libmongoc is 1.x, so libmongoc 2.5.5 is built from
source and cached, as Perfect-MongoDB's CI does. SESSION_REQUIRE_LIVE=1 makes
a backend whose tests aren't switched on, or can't reach its server, fail
the job instead of skipping. MYSQL_PORT/PG_PORT/REDIS_PORT are explicit.

macOS: macos-26 with Xcode 26.0.1 and 26.6, debug and release; no database
servers there, so SQLite runs and the other live suites report as skipped.
No iOS job: the package declares only macOS (and builds on Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
English README: install with from: "4.0.0"; accurate requirements and CI
coverage; a session-security section (tokens, server-side expiry and
absoluteTimeout, regenerate() against fixation, SessionCookie, JSON-only
data, set-once configuration); a Perfect-NIO 0.1 session-filter example
(compiled against Perfect-NIO 0.1.0 with warnings as errors); per-backend
notes (Redis keyPrefix and commandTimeout, 64-bit SQL columns); the test
variables including the ports and SESSION_REQUIRE_LIVE; known limitations;
"Migrating from 3.x" and "From the Swift 6 main branch" (with the ALTER
TABLE statements); the legacy branch. README.zh_CN.md gets the same
structure in summary and points to the English README for details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entication

AuthFilter.shouldWeAccept compared raw strings, so behind Perfect-NIO 0.1.0
(whose router matches case-insensitively) /Admin, /ADMIN and /Admin/users
reached /admin handlers with no authentication: the review got 200s from a
live NIO app. //admin, /./admin, /admin/, /%61dmin and /admin;x=1 weren't
matched either, a "*.json" pattern never matched, and "/admin*" covered
/administrator.

Add AuthFilter.requiresAuthentication(_:using:):
- the path is split on /, with empty and . segments dropped;
- segments compare ignoring case, unless the new
  AuthenticationConfig.caseInsensitive (default true) is turned off;
- a pattern covers its own path and everything below it, by whole segments
  (/admin covers /admin/users, not /administrator);
- * is a wildcard only as a pattern's whole final segment; an inclusion that
  uses it any other way requires authentication everywhere, and such an
  exclusion matches nothing;
- when anything is protected, a path with a .. segment, %, ; or \ requires
  authentication (fail closed).

shouldWeAccept, whose name suggested the opposite of what it returns, is
deprecated and now calls requiresAuthentication. AuthenticationConfig.denied,
which nothing reads, is deprecated.

Reproduced: the new tests failed with 18 issues against the old matching
(every case and normalization variant, the partial wildcard, /admin/*
not covering /admin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One non-JSON value in session.data (a Date, say) made tojson() return
"{}" and the Redis driver save empty data: every backend but memory saved
the session with ALL its data gone, CSRF token included, while userid
survived, so the user stayed signed in and a CSRF check comparing against
data["csrf"] ?? "" could pass. Add PerfectSession.jsonSafeData(), which
drops only the top-level keys whose values JSON can't hold and logs their
names (never their values) at warning level; a csrf value that isn't JSON
is replaced with a new token rather than dropped. tojson() and the Redis
encoder use it. Every driver's resume() now calls setCSRF(), so a stored
session without a CSRF token gets one.

PerfectSessionCore now depends on swift-log for that warning.

Reproduced: nonJSONValueDropsOnlyThatKey and resumeRestoresMissingCSRF
failed on MySQL, PostgreSQL, SQLite, Redis and MongoDB (3 issues each),
and the core tojson tests failed (15 issues), before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the Redis driver called touch() in save(). On the memory, MySQL,
PostgreSQL, SQLite and MongoDB drivers a session expired idle seconds after
it was created however often it was saved, contrary to the README and to
SessionConfig.idle's doc ("each save renews it"), and unlike 3.1.5's memory
driver, which touched on save. Every save() now touches.

Tests that expired a session by back-dating updated before save() now use a
negative idle, since save() sets updated.

Reproduced: saveRenewsIdle (idle 5, saves 3 s apart, resumed 6 s after
creation) failed on memory, MySQL, PostgreSQL, SQLite and MongoDB before
the change. An earlier version of the test with idle 3 was flaky from
whole-second rounding, Redis included; idle 5 passed 18 of 18 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default regenerate() gave the new session a new created, so every
sign-in (or any regenerate) restarted SessionConfig.absoluteTimeout. It now
copies created from the old session, and the SQL drivers' save() writes
created (MySQL, PostgreSQL and SQLite only updated userid, updated, idle and
data, so the copied value would have been lost on those backends).

Reproduced: regenerateKeepsCreated failed on all six drivers (memory, MySQL,
PostgreSQL, SQLite, Redis, MongoDB) before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Redis, SQLite and MongoDB drivers put the full session token in
"eventid" log metadata on every failure (and MongoDB's save on a debug
line), so anyone who could read the logs could take over those sessions.
Add PerfectSession.logID(_:), the first 12 hex digits of the token's
SHA-256 (a small FIPS 180-4 implementation in the core, checked against
the standard vectors), and log it as "session" metadata instead. No other
log line includes a token.

Reproduced: redis/sqlite/mongoDBLogsDoNotContainTokens, exit tests that
bootstrap a capturing log handler and drive a driver whose backend is
unreachable, failed (token found in the logs) before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These settings were carried over from 3.x but nothing in 4.0 reads them,
so code that sets them (requireToken, failAction...) gets no enforcement and
no warning. Each is now deprecated with a message saying it has no effect in
4.0 and what to do instead: SessionConfig.CSRF.failAction, checkState,
checkHeaders and requireToken; SessionConfig.CORS.methods, customHeaders,
withCredentials and maxAge; SessionConfig.purgeInterval, healthCheckRoute
and isOAuth2; PerfectSession._isOAuth2 (no longer initialized from
isOAuth2: neither was ever stored); AuthenticationConfig.denied was
deprecated in the AuthFilter commit.

Add CSRFSecurity.tokenMatches(_:_:), a comparison of the presented and
session CSRF tokens whose time doesn't depend on where they differ; an empty
token never matches. There was no helper, and the README never said how to
check the token (the README commit adds a middleware example).

New API, so csrfTokenMatches is a new test rather than a reproduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An idle near Int.max became a PX argument that Perfect-Redis (Int
milliseconds) and Redis (now + PX must fit in 64 bits) refuse, so save()
failed, logged, and the session's new data was lost. The TTL is now capped
at 10^12 seconds (about 31,700 years); the session's own idle is still
stored unchanged.

Reproduced: hugeIdleStillSaves (idle Int.max, Int.max / 1000, 2^50) failed
with 4 issues before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tim's decision. RedisSessionConnector.commandTimeout was nil, so a Redis
server that stopped answering held every request that touched a session
indefinitely. It now defaults to 5 seconds; set nil to wait indefinitely,
or another value. Behaviour change: a Redis call that takes longer than
5 s now throws and its connection is closed (create/save/destroy log the
error; resume throws InvalidSessionError).

Test: commandTimeoutDefaultsToFiveSeconds checks the default and resumes
against a TCP listener that never answers. Before the change the call was
still waiting when the test's 15 s guard fired; now it throws after about
5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browsers match the cookie name prefixes ignoring case, but SessionCookie
only recognized "__Host-" and "__Secure-" exactly: a "__HOST-" name with
a Domain got a header browsers reject, and "__host-"/"__SECURE-" names
didn't get Secure.

Reproduced: sameSiteNoneAndPrefixesForceSecure failed with 3 issues before
the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isValid(origin:host:) stripped the scheme from both sides and compared the
rest exactly, so an http:// Origin passed for an https:// host, an Origin
of https://example.com didn't match a Host of example.com:443, and the
Host header's case mattered. It now compares lowercased host[:port]s, treats
:443 (https) and :80 (http) like no port, requires the schemes to match
when both sides give one (a side without one takes the other's), and never
accepts an empty or "null" Origin. acceptableHostnames entries are compared
the same way.

Reproduced: csrfSecurityComparesSchemePortAndCase failed (7 issues with the
updated csrfSecurityStripsScheme, which had asserted the http/https match)
before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SessionConfig.absoluteTimeout was enforced by isExpired() and resume() but
storage ignored it: clean() deleted only sessions past their idle time, the
MongoDB TTL index expired documents at updated + idle, and Redis keys lived
for idle seconds after each save. Add PerfectSession.expiry(absoluteTimeout:)
(the earlier of updated + idle and created + absoluteTimeout) and use it:
every driver's clean() also deletes sessions created absoluteTimeout or more
seconds ago (overflow-safe on each backend), MongoDB's expiresAt is the
expiry, and the Redis TTL runs to it (at least 1 second; save() deletes a
session already past it).

clean() reads SessionConfig.absoluteTimeout through an internal
clean(absoluteTimeout:), which the tests call so they don't change shared
settings under other suites. cleanHonoursAbsoluteTimeout (MySQL, PostgreSQL,
SQLite), cleanAndTTLHonourAbsoluteTimeout (MongoDB),
memoryCleanHonoursAbsoluteTimeout, ttlHonoursAbsoluteTimeout (Redis) and
expiryTakesTheEarlierLimit are new: before this commit the old clean()
deleted nothing for an active session past the absolute timeout, but there
was no parameter to test it through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
taplin and others added 3 commits October 14, 2026 10:32
- Session security: every save renews the idle time; absoluteTimeout also
  governs clean(), the MongoDB TTL and Redis TTLs; regenerate keeps the
  creation time; a CSRF section (tokenMatches, the Origin check); the
  AuthFilter rules (segments, case-folding, wildcards, fail-closed
  characters); CORS pattern advice (no trailing *, "*" accepts null,
  credentials are the app's job, also in CORSSecurity's doc comment);
  non-JSON values drop only their own key; logs carry logID, not tokens.
- A Perfect-NIO 0.1 middleware example running requiresAuthentication,
  tokenMatches and isValid after loadSession.
- Redis: commandTimeout defaults to 5 s; TTL notes.
- Known limitations: AuthFilter doesn't percent-decode; no TLS/sslmode
  options on the MySQL and PostgreSQL connectors; those drivers don't log
  database errors; MySQL token comparison follows the column collation.
- Migrating from 3.x: PerfectSessionManager -> SessionDriver (new create
  requirement), couchDatabase/mongoCollection removed, cookieSameSite and
  CORS.methods type changes, the deprecated no-effect settings,
  shouldWeAccept -> requiresAuthentication.
- From main: save renews idle, Redis 5 s timeout, per-key JSON dropping,
  AuthFilter and CSRF matching changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under ThreadSanitizer every exit test aborts with SIGABRT, even one whose
body only calls exit(0) (checked on Xcode 27), so the three
LogsDoNotContainTokens tests are skipped there, as the fd-starvation test
already was. MySQL's timesPast32BitsRoundTrip compared updated across a
save(), which now renews it; under Guard Malloc the slower run crossed a
second boundary and failed. It now checks updated didn't go backwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@taplin
taplin merged commit 0b3bf33 into main Oct 14, 2026
16 checks passed
@taplin
taplin deleted the release-prep branch October 14, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant