From 093b751403d49027afd4cae36588d9176cf5139b Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:53:07 -0500 Subject: [PATCH 01/33] Pin released dependencies: MySQL 4, PostgreSQL 5, Redis 4, SQLite 6, 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 --- Package.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index df3ede2..b4832f9 100644 --- a/Package.swift +++ b/Package.swift @@ -13,11 +13,11 @@ let package = Package( .library(name: "PerfectSessionMongoDB", targets: ["PerfectSessionMongoDB"]), ], dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.2"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", from: "4.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", from: "5.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", from: "4.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", from: "6.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ], targets: [ From 8787954349f8ae3dbd609962fd9dd05e3a95d3ae Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:55:26 -0500 Subject: [PATCH 02/33] Tests: live suites skip visibly, fail when unreachable or required 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 --- .../PerfectSessionMongoDBTests.swift | 51 ++++++++++++------- .../PerfectSessionMySQLTests.swift | 40 ++++++++++++--- .../PerfectSessionPostgreSQLTests.swift | 32 +++++++++--- .../PerfectSessionRedisTests.swift | 32 +++++++++--- .../PerfectSessionSQLiteTests.swift | 22 ++++---- 5 files changed, 127 insertions(+), 50 deletions(-) diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 85c7bd0..796dff2 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -4,15 +4,8 @@ import Foundation import PerfectSessionCore import PerfectMongoDB -/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test -@Suite(.serialized) struct PerfectSessionMongoDBTests { - - static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1" - static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost" - - func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver { - try MongoDBSessionDriver(uri: Self.uri, database: "perfect_sessions_test", collection: collection) - } +/// SessionDocument encoding; no server needed. +@Suite struct MongoDBSessionDocumentTests { @Test func documentRoundTripKeepsDataTypes() throws { var session = PerfectSession() @@ -68,9 +61,22 @@ import PerfectMongoDB #expect((nested?["in\u{0}ner"] as? [String: Any])?["%"] as? Int == 3) #expect(((restored.data["list"] as? [Any])?.first as? [String: Any])?[""] as? Int == 4) } +} + +/// Live-test settings, outside the suite so its traits can refer to them. +enum MongoDBLive { + static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1" + static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost" +} + +/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test +@Suite(.serialized, .enabled(if: MongoDBLive.mongoEnabled, "set MONGODB_TESTS=1 to run against a server")) struct PerfectSessionMongoDBTests { + + func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver { + try MongoDBSessionDriver(uri: MongoDBLive.uri, database: "perfect_sessions_test", collection: collection) + } @Test func emptyKeySavesAndResumes() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.data[""] = "empty" @@ -83,7 +89,6 @@ import PerfectMongoDB } @Test func saveAfterDestroyDoesNotResurrect() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.userid = "logged-out" @@ -95,7 +100,6 @@ import PerfectMongoDB } @Test func setupPurgesLegacySessionsOnly() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver(collection: "sessions_legacy_test") func insertLegacy(_ token: String) async throws { // The shape Perfect-Session-MongoDB wrote: its own _id, the token in a field, data as JSON text. @@ -145,7 +149,6 @@ import PerfectMongoDB } @Test func createAndResume() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "MongoTest/1.0") #expect(!session.token.isEmpty) @@ -159,7 +162,6 @@ import PerfectMongoDB } @Test func resumeMissing() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -167,7 +169,6 @@ import PerfectMongoDB } @Test func saveAndResume() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.userid = "mongo-user-1" @@ -182,7 +183,6 @@ import PerfectMongoDB } @Test func destroyRemovesSession() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -192,7 +192,6 @@ import PerfectMongoDB } @Test func dataIsStoredAsSubdocument() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.data["role"] = "admin" @@ -207,7 +206,6 @@ import PerfectMongoDB } @Test func setupCreatesTTLIndexAndCleanRemovesExpired() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver(collection: "sessions_ttl_test") try await driver.pool.withClient { client in _ = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test").drop() @@ -233,4 +231,21 @@ import PerfectMongoDB _ = try await driver.resume(token: live.token) await driver.destroy(token: live.token) } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async throws { + let driver = try getDriver() + let reachable = try await driver.pool.withClient { client in + if case .replyDoc = client.serverStatus() { return true } + return false + } + #expect(reachable, "can't reach MongoDB at \(MongoDBLive.uri)") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MONGODB_TESTS fails rather than passing with +/// the live suite skipped. +@Test func mongoDBLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || MongoDBLive.mongoEnabled, "SESSION_REQUIRE_LIVE=1 but MONGODB_TESTS is not 1") } diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 7c60d52..5049ec7 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -2,17 +2,24 @@ import Testing import Foundation @testable import PerfectSessionMySQL import PerfectSessionCore +import PerfectMySQL -// Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionMySQLTests { +/// Live-test settings, outside the suite so its traits can refer to them. +enum MySQLLive { + /// MYSQL_PORT is required too, so the tests never fall back to a developer's own server on 3306. + static let port = Int(ProcessInfo.processInfo.environment["MYSQL_PORT"] ?? "") + static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" && port != nil +} - static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" +// Serialized: each test sets the connector's global settings. +@Suite(.serialized, .enabled(if: MySQLLive.mysqlEnabled, "set MYSQL_TESTS=1 and MYSQL_PORT to run against a server")) struct PerfectSessionMySQLTests { func getDriver() async -> MySQLSessionDriver { MySQLSessionConnector.host = ProcessInfo.processInfo.environment["MYSQL_HOST"] ?? "localhost" MySQLSessionConnector.username = ProcessInfo.processInfo.environment["MYSQL_USER"] ?? "root" MySQLSessionConnector.password = ProcessInfo.processInfo.environment["MYSQL_PASSWORD"] ?? "" MySQLSessionConnector.database = ProcessInfo.processInfo.environment["MYSQL_DATABASE"] ?? "perfect_sessions_test" + MySQLSessionConnector.port = MySQLLive.port ?? 0 MySQLSessionConnector.table = "test_sessions" let driver = MySQLSessionDriver() await driver.setup() @@ -20,7 +27,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.mysqlEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "TestAgent/1.0") #expect(!session.token.isEmpty) @@ -33,7 +39,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -41,7 +46,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.mysqlEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "mysql-user-1" @@ -54,7 +58,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -64,7 +67,6 @@ import PerfectSessionCore } @Test func clean() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() var session = await driver.create() session.idle = 1 @@ -75,4 +77,26 @@ import PerfectSessionCore _ = try await driver.resume(token: session.token) } } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async { + _ = await getDriver() + let db = MySQL() + let connected = db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)) + #expect(connected, "can't reach MySQL at \(MySQLSessionConnector.host):\(MySQLSessionConnector.port): \(db.errorMessage())") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MYSQL_TESTS fails rather than passing with +/// the live suite skipped. +@Test func mysqlLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || MySQLLive.mysqlEnabled, "SESSION_REQUIRE_LIVE=1 but MYSQL_TESTS is not 1") +} + +@Test func mysqlLiveTestsHaveAPort() { + let requested = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" + #expect(!requested || MySQLLive.port != nil, "MYSQL_TESTS=1 needs MYSQL_PORT") } diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index fc2b41f..72c3a7f 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -2,17 +2,22 @@ import Testing import Foundation @testable import PerfectSessionPostgreSQL import PerfectSessionCore +import PerfectPostgreSQL -// Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionPostgreSQLTests { - +/// Live-test settings, outside the suite so its traits can refer to them. +enum PostgreSQLLive { static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" +} + +// Serialized: each test sets the connector's global settings. +@Suite(.serialized, .enabled(if: PostgreSQLLive.pgEnabled, "set PG_TESTS=1 to run against a server")) struct PerfectSessionPostgreSQLTests { func getDriver() async -> PostgreSQLSessionDriver { PostgreSQLSessionConnector.host = ProcessInfo.processInfo.environment["PG_HOST"] ?? "localhost" PostgreSQLSessionConnector.username = ProcessInfo.processInfo.environment["PG_USER"] ?? "postgres" PostgreSQLSessionConnector.password = ProcessInfo.processInfo.environment["PG_PASSWORD"] ?? "" PostgreSQLSessionConnector.database = ProcessInfo.processInfo.environment["PG_DATABASE"] ?? "perfect_sessions_test" + PostgreSQLSessionConnector.port = Int(ProcessInfo.processInfo.environment["PG_PORT"] ?? "") ?? 5432 PostgreSQLSessionConnector.table = "test_sessions" let driver = PostgreSQLSessionDriver() await driver.setup() @@ -20,7 +25,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.pgEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "192.168.1.1", useragent: "TestAgent/1.0") #expect(!session.token.isEmpty) @@ -32,7 +36,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.pgEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -40,7 +43,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.pgEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "pg-user-1" @@ -53,7 +55,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.pgEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -63,7 +64,6 @@ import PerfectSessionCore } @Test func clean() async { - guard Self.pgEnabled else { return } let driver = await getDriver() var session = await driver.create() session.idle = 1 @@ -74,4 +74,20 @@ import PerfectSessionCore _ = try await driver.resume(token: session.token) } } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async { + _ = await getDriver() + let db = PGConnection() + defer { db.close() } + #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, + "can't reach PostgreSQL: \(db.errorMessage())") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets PG_TESTS fails rather than passing with +/// the live suite skipped. +@Test func postgreSQLLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || PostgreSQLLive.pgEnabled, "SESSION_REQUIRE_LIVE=1 but PG_TESTS is not 1") } diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 66b7ff2..c1dcdc3 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -2,11 +2,15 @@ import Testing import Foundation @testable import PerfectSessionRedis import PerfectSessionCore +import PerfectRedis -// Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionRedisTests { - +/// Live-test settings, outside the suite so its traits can refer to them. +enum RedisLive { static let redisEnabled = ProcessInfo.processInfo.environment["REDIS_TESTS"] == "1" +} + +// Serialized: each test sets the connector's global settings. +@Suite(.serialized, .enabled(if: RedisLive.redisEnabled, "set REDIS_TESTS=1 to run against a server")) struct PerfectSessionRedisTests { func getDriver() -> RedisSessionDriver { RedisSessionConnector.host = ProcessInfo.processInfo.environment["REDIS_HOST"] ?? "127.0.0.1" @@ -16,7 +20,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.redisEnabled else { return } let driver = getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "RedisTest/1.0") #expect(!session.token.isEmpty) @@ -28,7 +31,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.redisEnabled else { return } let driver = getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -36,7 +38,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.redisEnabled else { return } let driver = getDriver() var session = await driver.create() session.userid = "redis-user-1" @@ -49,7 +50,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.redisEnabled else { return } let driver = getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -57,4 +57,22 @@ import PerfectSessionCore _ = try await driver.resume(token: session.token) } } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async throws { + _ = getDriver() + let client = try await RedisClient.connect(withIdentifier: RedisClientIdentifier( + withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, + password: RedisSessionConnector.password, commandTimeout: 10)) + let pong = try await client.ping() + #expect(pong.string == "PONG") + try await client.close() + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets REDIS_TESTS fails rather than passing with +/// the live suite skipped. +@Test func redisLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || RedisLive.redisEnabled, "SESSION_REQUIRE_LIVE=1 but REDIS_TESTS is not 1") } diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 036137a..97c530b 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -3,10 +3,13 @@ import Foundation @testable import PerfectSessionSQLite import PerfectSessionCore -// Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionSQLiteTests { - +/// Live-test settings, outside the suite so its traits can refer to them. +enum SQLiteLive { static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1" +} + +// Serialized: each test sets the connector's global settings. +@Suite(.serialized, .enabled(if: SQLiteLive.sqliteEnabled, "set SQLITE_TESTS=1 to run")) struct PerfectSessionSQLiteTests { func getDriver() async -> SQLiteSessionDriver { let tmp = FileManager.default.temporaryDirectory @@ -20,7 +23,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "127.0.0.1", useragent: "SQLiteTest/1.0") #expect(!session.token.isEmpty) @@ -32,7 +34,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.sqliteEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -40,7 +41,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "sqlite-user-1" @@ -53,7 +53,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.sqliteEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -63,7 +62,6 @@ import PerfectSessionCore } @Test func clean() async { - guard Self.sqliteEnabled else { return } let driver = await getDriver() var session = await driver.create() session.idle = 1 @@ -76,7 +74,6 @@ import PerfectSessionCore } @Test func dataRoundTrip() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() var session = await driver.create() session.data["string"] = "world" @@ -88,3 +85,10 @@ import PerfectSessionCore await driver.destroy(token: session.token) } } + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets SQLITE_TESTS fails rather than passing with +/// the live suite skipped. +@Test func sqliteLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || SQLiteLive.sqliteEnabled, "SESSION_REQUIRE_LIVE=1 but SQLITE_TESTS is not 1") +} From 4ba86567cbecae34eb7fc071963b41cb37651847 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:56:49 -0500 Subject: [PATCH 03/33] Don't crash saving session data JSON can't hold 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 --- Sources/PerfectSessionCore/PerfectSession.swift | 10 +++++++++- .../RedisSessionDriver.swift | 7 +++++-- .../PerfectSessionCoreTests.swift | 17 +++++++++++++++++ .../PerfectSessionRedisTests.swift | 12 ++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index bce6a22..c73af4d 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -7,6 +7,10 @@ public struct PerfectSession: @unchecked Sendable { public var created = 0 public var updated = 0 public var idle = SessionConfig.idle + /// The session's values. Store only JSON values: `String`, `Bool`, finite numbers, `NSNull`, + /// and arrays and `[String: …]` dictionaries of them. If anything else is in here (a `Date`, + /// a `URL`, NaN, a dictionary with non-string keys), `tojson()` returns `"{}"`, so the + /// drivers save the session with empty data. public var data = [String: Any]() public var ipaddress = "" public var useragent = "" @@ -19,8 +23,12 @@ public struct PerfectSession: @unchecked Sendable { updated = now } + /// `data` as JSON text, or `"{}"` if it holds a value JSON can't represent. public func tojson() -> String { - guard let d = try? JSONSerialization.data(withJSONObject: data), + // JSONSerialization raises an Objective-C exception for such values, which `try?` can't + // catch and which terminates the process, so check first. + guard JSONSerialization.isValidJSONObject(data), + let d = try? JSONSerialization.data(withJSONObject: data), let s = String(data: d, encoding: .utf8) else { return "{}" } return s } diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 7123c16..1f14ac2 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -98,9 +98,12 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { "idle": session.idle, "ipaddress": session.ipaddress, "useragent": session.useragent, - "data": session.data, + "data": JSONSerialization.isValidJSONObject(session.data) ? session.data : [:], ] - guard let d = try? JSONSerialization.data(withJSONObject: dict), + // JSONSerialization raises an uncatchable exception for a value JSON can't hold. Like + // tojson(), save such data as empty rather than terminate the process. + guard JSONSerialization.isValidJSONObject(dict), + let d = try? JSONSerialization.data(withJSONObject: dict), let s = String(data: d, encoding: .utf8) else { return nil } return s } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 0683f2f..f778102 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -32,6 +32,23 @@ import Foundation #expect(s.tojson() == "{}") } + /// JSONSerialization raises an Objective-C exception (on Darwin), which Swift can't catch, + /// for values JSON can't hold; tojson() used to crash the process on them. + @Test(arguments: ["nan", "infinity", "date", "url", "intKey", "nestedDate"]) + func sessionToJsonNonJSONValueDoesNotCrash(_ kind: String) { + var s = PerfectSession() + s.data["ok"] = "kept out" + switch kind { + case "nan": s.data["x"] = Double.nan + case "infinity": s.data["x"] = Double.infinity + case "date": s.data["x"] = Date() + case "url": s.data["x"] = URL(string: "https://example.com")! + case "intKey": s.data["x"] = [1: "one"] + default: s.data["x"] = ["when": [Date()]] + } + #expect(s.tojson() == "{}") + } + @Test func sessionFromJsonBadInput() { var s = PerfectSession() s.data["key"] = "before" diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index c1dcdc3..313f9de 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -49,6 +49,18 @@ enum RedisLive { await driver.destroy(token: session.token) } + @Test func saveWithNonJSONValueDoesNotCrash() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "redis-user-2" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "redis-user-2") + #expect(resumed.data.isEmpty) + await driver.destroy(token: session.token) + } + @Test func destroy() async { let driver = getDriver() let session = await driver.create() From df32683481e8e5c9b782bac53fee3b008b3ea78c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:57:26 -0500 Subject: [PATCH 04/33] CORS: a wildcard origin must match both sides of the * 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 --- Sources/PerfectSessionCore/CSRFSecurity.swift | 27 ++++++++++++++----- .../PerfectSessionCoreTests.swift | 26 ++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index 9c327de..1839a99 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -27,18 +27,33 @@ public struct CSRFSecurity: Sendable { /// Validates CORS origin against SessionConfig.CORS.acceptableHostnames. public struct CORSSecurity: Sendable { + /// True if **origin** matches an entry in `SessionConfig.CORS.acceptableHostnames`, ignoring + /// case. `"*"` accepts every origin; in any other entry each `*` matches any run of + /// characters, and the rest must match in full (`"https://*.example.com"`). public static func isAcceptable(origin: String) -> Bool { guard SessionConfig.CORS.enabled, !SessionConfig.CORS.acceptableHostnames.isEmpty else { return false } let lower = origin.lowercased() - if SessionConfig.CORS.acceptableHostnames.contains("*") { return true } - if SessionConfig.CORS.acceptableHostnames.contains(lower) { return true } - for pattern in SessionConfig.CORS.acceptableHostnames where pattern.contains("*") && pattern != "*" { - let parts = pattern.split(separator: "*") - if let first = parts.first, lower.starts(with: String(first)) { return true } - if let last = parts.last, lower.hasSuffix(String(last)) { return true } + for pattern in SessionConfig.CORS.acceptableHostnames { + if pattern == "*" || matches(lower, pattern: pattern.lowercased()) { return true } } return false } + + /// Glob match where `*` is the only wildcard. Every literal piece must match, in order, with + /// the first anchored at the start and the last at the end. + static func matches(_ text: String, pattern: String) -> Bool { + let pieces = pattern.split(separator: "*", omittingEmptySubsequences: false) + guard pieces.count > 1 else { return text == pattern } + var rest = Substring(text) + guard let first = pieces.first, rest.hasPrefix(first) else { return false } + rest = rest.dropFirst(first.count) + guard let last = pieces.last else { return false } + for piece in pieces.dropFirst().dropLast() where !piece.isEmpty { + guard let range = rest.range(of: piece) else { return false } + rest = rest[range.upperBound...] + } + return rest.count >= last.count && rest.hasSuffix(last) + } } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index f778102..f0de632 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -226,6 +226,32 @@ import Foundation #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com")) } + // MARK: - CORSSecurity + + /// A wildcard pattern used to accept any origin that matched its prefix OR its suffix, so + /// "https://*.example.com" accepted every https origin. + @Test func corsWildcardNeedsPrefixAndSuffix() { + let prev = SessionConfig.CORS + defer { SessionConfig.CORS = prev } + SessionConfig.CORS.enabled = true + SessionConfig.CORS.acceptableHostnames = ["https://*.example.com", "http://localhost:*"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com")) + #expect(CORSSecurity.isAcceptable(origin: "https://A.Example.com")) + #expect(CORSSecurity.isAcceptable(origin: "http://localhost:8080")) + #expect(!CORSSecurity.isAcceptable(origin: "https://evil.com")) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.com.evil.com")) + #expect(!CORSSecurity.isAcceptable(origin: "http://app.example.com")) + #expect(!CORSSecurity.isAcceptable(origin: "https://localhost:8080")) + + SessionConfig.CORS.acceptableHostnames = ["*.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com")) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.org")) + + SessionConfig.CORS.acceptableHostnames = ["https://Exact.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://exact.example.com")) + #expect(!CORSSecurity.isAcceptable(origin: "https://other.example.com")) + } + // MARK: - CookieSameSite @Test func cookieSameSiteRawValues() { From 763cd22c1f7b77d2667e2041d822df5339bed42e Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 07:59:31 -0600 Subject: [PATCH 05/33] Redis sessions: namespaced keys, no resurrection, TTL from the session 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 --- .../RedisSessionDriver.swift | 92 +++++++++++++------ .../PerfectSessionRedisTests.swift | 83 +++++++++++++++++ 2 files changed, 147 insertions(+), 28 deletions(-) diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 1f14ac2..2b25b91 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -5,14 +5,23 @@ import Logging private let logger = Logger(label: "perfect.session.redis") +/// Connection settings used by `RedisSessionDriver`. Set them once at startup. public struct RedisSessionConnector: Sendable { nonisolated(unsafe) public static var host: String = "127.0.0.1" nonisolated(unsafe) public static var password: String = "" nonisolated(unsafe) public static var port: Int = 6379 + /// Prepended to each token to make its Redis key. Tokens come from clients' cookies, so the + /// prefix keeps a token from naming one of the application's own keys. + nonisolated(unsafe) public static var keyPrefix: String = "perfect-session:" + /// Seconds each Redis call may take (see Perfect-Redis's `RedisClientIdentifier`), or nil to + /// wait indefinitely. + nonisolated(unsafe) public static var commandTimeout: Double? = nil private init() {} } -public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { +/// Stores each session as a JSON string under `keyPrefix + token`, with a TTL of the session's +/// `idle` seconds, renewed by every `save`. Each call opens its own connection. +public final class RedisSessionDriver: SessionDriver, Sendable { public init() {} public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { @@ -22,14 +31,16 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { session.useragent = useragent session._state = "new" session.setCSRF() + guard session.idle > 0 else { + logger.error("session not stored: idle must be positive, not \(session.idle)") + return session + } guard let json = encode(session) else { return session } do { - let client = try await connect() - _ = try await client.set( - key: session.token, value: .string(json), - expires: Double(SessionConfig.idle) - ) - try? await client.close() + _ = try await withClient { client in + try await client.set(key: self.key(session.token), value: .string(json), + expires: Double(session.idle), ifNotExists: true) + } } catch { logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) } @@ -37,33 +48,42 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { } public func resume(token: String) async throws -> PerfectSession { - let client = try await connect() + let response: RedisResponse do { - let response = try await client.get(key: token) - try? await client.close() - guard let json = response.string, !json.isEmpty else { throw InvalidSessionError() } - guard var session = decode(token: token, json: json) else { throw InvalidSessionError() } - session._state = "resume" - return session - } catch let e as InvalidSessionError { - throw e + response = try await withClient { try await $0.get(key: self.key(token)) } } catch { - try? await client.close() + logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"]) + throw InvalidSessionError() + } + guard let json = response.string, !json.isEmpty, + var session = decode(token: token, json: json) else { throw InvalidSessionError() } + session._state = "resume" + return session } + /// Updates a stored session and renews its TTL. A session that is no longer stored (it was + /// destroyed or has expired) isn't stored again, so a save racing a logout can't bring the + /// session back. A session whose `idle` is 0 or less is deleted. public func save(_ session: PerfectSession) async { var s = session s.touch() - guard let json = encode(s) else { return } do { - let client = try await connect() - _ = try await client.set( - key: s.token, value: .string(json), - expires: Double(SessionConfig.idle) - ) - try? await client.close() + guard s.idle > 0 else { + _ = try await withClient { try await $0.delete(keys: self.key(s.token)) } + return + } + guard let json = encode(s) else { return } + let token = s.token, idle = s.idle + let reply = try await withClient { client in + try await client.set(key: self.key(token), value: .string(json), + expires: Double(idle), ifExists: true) + } + if reply.isNil { + logger.debug("session save matched no session (destroyed or expired); not recreated", + metadata: ["eventid": "\(s.token)"]) + } } catch { logger.error("session save failed: \(error)", metadata: ["eventid": "\(s.token)"]) } @@ -71,9 +91,7 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { public func destroy(token: String) async { do { - let client = try await connect() - try await client.delete(keys: token) - try? await client.close() + _ = try await withClient { try await $0.delete(keys: self.key(token)) } } catch { logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) } @@ -81,11 +99,29 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { // MARK: - Private helpers + private func key(_ token: String) -> String { + RedisSessionConnector.keyPrefix + token + } + + /// Runs **body** on a new connection, then closes it. + private func withClient(_ body: (RedisClient) async throws -> T) async throws -> T { + let client = try await connect() + do { + let result = try await body(client) + try? await client.close() + return result + } catch { + try? await client.close() + throw error + } + } + private func connect() async throws -> RedisClient { let id = RedisClientIdentifier( withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, - password: RedisSessionConnector.password + password: RedisSessionConnector.password, + commandTimeout: RedisSessionConnector.commandTimeout ) return try await RedisClient.connect(withIdentifier: id) } diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 313f9de..70a54ba 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -70,6 +70,89 @@ enum RedisLive { } } + func rawClient() async throws -> RedisClient { + try await RedisClient.connect(withIdentifier: RedisClientIdentifier( + withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, + password: RedisSessionConnector.password, commandTimeout: 10)) + } + + /// A save racing a logout used to SET the key again and bring the session back. + @Test func saveAfterDestroyDoesNotResurrect() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// The key's TTL follows the session's own idle time, not SessionConfig.idle. + @Test func expiryFollowsSessionIdle() async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = 1 + await driver.save(session) + try await Task.sleep(for: .milliseconds(1500)) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// An idle time of 0 or less used to store the session with no TTL at all. + @Test func nonPositiveIdleDoesNotMakeASessionImmortal() async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = 0 + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// Tokens come from the client's cookie. Session keys are namespaced, so a token can't name + /// (and resume or delete) one of the application's own keys. + @Test func tokenCannotReachOtherKeys() async throws { + let driver = getDriver() + let client = try await rawClient() + let key = "app:user:\(UUID().uuidString)" + _ = try await client.set(key: key, value: .string(#"{"userid":"admin","data":{}}"#)) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: key) + } + await driver.destroy(token: key) + #expect(try await client.get(key: key).string != nil) + _ = try await client.delete(keys: key) + try await client.close() + } + + /// Keys go to Redis as bulk strings, so separators, spaces and CR/LF in a token are just bytes. + @Test(arguments: ["a b", "a:b", "x\r\nFLUSHALL\r\n", "*1\r\n$4\r\nPING", "", "\u{0}"]) + func awkwardTokensAreJustMissingSessions(_ token: String) async throws { + let driver = getDriver() + let client = try await rawClient() + let canary = "canary:\(UUID().uuidString)" + _ = try await client.set(key: canary, value: .string("alive")) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + #expect(try await client.get(key: canary).string == "alive") + _ = try await client.delete(keys: canary) + try await client.close() + } + + @Test func keysAreNamespaced() async throws { + let driver = getDriver() + let session = await driver.create() + let client = try await rawClient() + #expect(try await client.get(key: RedisSessionConnector.keyPrefix + session.token).string != nil) + #expect(try await client.get(key: session.token).isNil) + try await client.close() + await driver.destroy(token: session.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async throws { _ = getDriver() From 9a5a892ccdd0940269ae5b1bf9d0819f7be922b7 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:00:07 -0600 Subject: [PATCH 06/33] Memory sessions: save() no longer recreates a destroyed session 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 --- .../MemorySessionDriver.swift | 6 +++++- .../PerfectSessionCoreTests.swift | 20 +++++++++++++++++++ .../PerfectSessionMySQLTests.swift | 11 ++++++++++ .../PerfectSessionPostgreSQLTests.swift | 11 ++++++++++ .../PerfectSessionSQLiteTests.swift | 11 ++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index a5954be..e27ac84 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -26,8 +26,12 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { return session } + /// Updates a stored session. A session that isn't stored (destroyed, cleaned, or never + /// created by this driver) isn't stored again, so a save racing a logout can't bring it back. public func save(_ session: PerfectSession) async { - lock.withLock { sessions[session.token] = session } + lock.withLock { + if sessions[session.token] != nil { sessions[session.token] = session } + } } public func destroy(token: String) async { diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index f0de632..908e8b0 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -160,6 +160,26 @@ import Foundation } } + /// save() used to insert, so a save racing a logout brought the session back, and saving a + /// session built around a client-chosen token stored it (session fixation). + @Test func memorySaveOnlyUpdatesStoredSessions() async { + let driver = MemorySessionDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + + var planted = PerfectSession() + planted.token = "attacker-chosen" + await driver.save(planted) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: "attacker-chosen") + } + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 5049ec7..dfb65d1 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -66,6 +66,17 @@ enum MySQLLive { } } + @Test func saveAfterDestroyDoesNotResurrect() async { + let driver = await getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 72c3a7f..fbadee8 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -63,6 +63,17 @@ enum PostgreSQLLive { } } + @Test func saveAfterDestroyDoesNotResurrect() async { + let driver = await getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 97c530b..4e22ed1 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -61,6 +61,17 @@ enum SQLiteLive { } } + @Test func saveAfterDestroyDoesNotResurrect() async { + let driver = await getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() From c7b53aeccbd4e95202657ac802aebdc208e4b15c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:01:41 -0500 Subject: [PATCH 07/33] Don't trap on a huge session idle time 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 --- .../MemorySessionDriver.swift | 2 +- .../PerfectSessionCore/PerfectSession.swift | 10 +++++++++- .../MongoDBSessionDriver.swift | 7 ++++++- .../PerfectSessionCoreTests.swift | 18 ++++++++++++++++++ .../PerfectSessionMongoDBTests.swift | 12 ++++++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index e27ac84..c4a07d9 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -41,7 +41,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { public func clean() async { let now = Int(Date().timeIntervalSince1970) lock.withLock { - sessions = sessions.filter { _, s in (s.updated + s.idle) > now } + sessions = sessions.filter { _, s in s.expires > now } } } } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index c73af4d..33da402 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -39,6 +39,14 @@ public struct PerfectSession: @unchecked Sendable { data = obj } + /// When the session expires (seconds since 1970): `updated + idle`, clamped instead of + /// overflowing, so an `idle` of `Int.max` means "never". + public var expires: Int { + let (sum, overflow) = updated.addingReportingOverflow(idle) + guard overflow else { return sum } + return idle > 0 ? Int.max : Int.min + } + public mutating func touch() { updated = Int(Date().timeIntervalSince1970) } @@ -46,7 +54,7 @@ public struct PerfectSession: @unchecked Sendable { /// Returns false if the session has expired or IP/UA locks fail. public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "") -> Bool { let now = Int(Date().timeIntervalSince1970) - guard (updated + idle) > now else { return false } + guard expires > now else { return false } if SessionConfig.IPAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } if SessionConfig.userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } return true diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index 6ebf75a..38de5d6 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -215,9 +215,14 @@ struct SessionDocument: Codable, Sendable { // Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber. data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:])) .mappingKeys(Self.escape) - expiresAt = Date(timeIntervalSince1970: TimeInterval(session.updated + session.idle)) + expiresAt = Date(timeIntervalSince1970: TimeInterval(min(max(session.expires, Self.earliestExpiry), Self.latestExpiry))) } + /// BSON dates are 64-bit milliseconds, and Perfect-MongoDB's encoder traps on a `Date` outside + /// that range, so `expiresAt` is clamped to years 0001 through 9999. + static let earliestExpiry = -62_135_596_800 + static let latestExpiry = 253_402_300_799 + var session: PerfectSession { var session = PerfectSession() session.token = _id diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 908e8b0..3bcaa11 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -90,6 +90,24 @@ import Foundation #expect(s.isValid()) } + /// `updated + idle` trapped on overflow, so an idle of Int.max ("never expire") crashed + /// isValid() and MemorySessionDriver.clean(). + @Test func hugeIdleDoesNotTrap() async throws { + var s = PerfectSession() + s.idle = Int.max + #expect(s.isValid()) + #expect(s.expires == Int.max) + s.idle = Int.min + #expect(!s.isValid()) + + let driver = MemorySessionDriver() + var stored = await driver.create() + stored.idle = Int.max + await driver.save(stored) + await driver.clean() + _ = try await driver.resume(token: stored.token) + } + @Test func sessionIsValidIPLock() { var s = PerfectSession() s.idle = 86400 diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 796dff2..70151a8 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -27,6 +27,18 @@ import PerfectMongoDB #expect(restored.data["name"] as? String == "x") } + /// `updated + idle` trapped on overflow when building the document, and a far-future + /// `expiresAt` trapped in the BSON encoder. + @Test(arguments: [Int.max, Int.max / 2, 1 << 50, Int.min, -(1 << 50)]) + func hugeIdleDoesNotTrap(_ idle: Int) throws { + var session = PerfectSession() + session.idle = idle + let document = SessionDocument(session) + let seconds = document.expiresAt.timeIntervalSince1970 + #expect(seconds <= 253_402_300_799 && seconds >= -62_135_596_800) + _ = try BSONEncoder().encode(document) + } + @Test func keysBSONCantHoldAreEscapedAndRestored() throws { let awkward = ["", "%", "%%", "50%", "%25", "%00", "a%b", "nul\u{0}x", "\u{0}", "plain", "a.b", "$x"] for key in awkward { From 7b1394f1115341f0bcce1137bf63a7748c27b62c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:03:06 -0500 Subject: [PATCH 08/33] MySQL/PostgreSQL sessions: 64-bit times, overflow-safe clean() 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 --- .../MySQLSessionDriver.swift | 29 ++++++++++---- .../PostgreSQLSessionDriver.swift | 9 +++-- .../PerfectSessionMySQLTests.swift | 40 +++++++++++++++++++ .../PerfectSessionPostgreSQLTests.swift | 38 ++++++++++++++++++ .../PerfectSessionSQLiteTests.swift | 10 +++++ 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 1acfb49..90cd470 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -21,9 +21,9 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { CREATE TABLE IF NOT EXISTS `\(MySQLSessionConnector.table)` ( `token` varchar(255) NOT NULL, `userid` varchar(255), - `created` int NOT NULL DEFAULT 0, - `updated` int NOT NULL DEFAULT 0, - `idle` int NOT NULL DEFAULT 0, + `created` bigint NOT NULL DEFAULT 0, + `updated` bigint NOT NULL DEFAULT 0, + `idle` bigint NOT NULL DEFAULT 0, `data` text, `ipaddress` varchar(255), `useragent` text, @@ -62,9 +62,9 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { var s = PerfectSession() s.token = row[0] as? String ?? "" s.userid = row[1] as? String ?? "" - s.created = Int(row[2] as? Int32 ?? 0) - s.updated = Int(row[3] as? Int32 ?? 0) - s.idle = Int(row[4] as? Int32 ?? 0) + s.created = Self.int(row[2]) + s.updated = Self.int(row[3]) + s.idle = Self.int(row[4]) if let d = row[5] as? String { s.fromjson(d) } s.ipaddress = row[6] as? String ?? "" s.useragent = row[7] as? String ?? "" @@ -88,13 +88,28 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { public func clean() async { exec( - "DELETE FROM `\(MySQLSessionConnector.table)` WHERE updated + idle < ?", + // DECIMAL arithmetic: a BIGINT sum overflows (an error) for an idle near Int.max. + "DELETE FROM `\(MySQLSessionConnector.table)` WHERE CAST(updated AS DECIMAL(30,0)) + idle < ?", params: [Int(Date().timeIntervalSince1970)] ) } // MARK: - Private helpers + /// An integer column: Int64 from the BIGINT columns `setup()` creates, Int32 from the INT + /// columns of tables created before 4.0. + private static func int(_ value: Any?) -> Int { + switch value { + case let v as Int64: return Int(v) + case let v as Int32: return Int(v) + case let v as Int: return v + case let v as UInt64: return Int(exactly: v) ?? Int.max + case let v as UInt32: return Int(v) + case let v as String: return Int(v) ?? 0 + default: return 0 + } + } + private func connect() -> MySQL { let db = MySQL() if MySQLSessionConnector.socket.isEmpty { diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index cbf484a..cb83be5 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -28,9 +28,9 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { CREATE TABLE IF NOT EXISTS "\(PostgreSQLSessionConnector.table)" ( "token" varchar NOT NULL, "userid" varchar, - "created" int4 NOT NULL DEFAULT 0, - "updated" int4 NOT NULL DEFAULT 0, - "idle" int4 NOT NULL DEFAULT 0, + "created" int8 NOT NULL DEFAULT 0, + "updated" int8 NOT NULL DEFAULT 0, + "idle" int8 NOT NULL DEFAULT 0, "data" text, "ipaddress" varchar, "useragent" text, @@ -90,7 +90,8 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { public func clean() async { exec( - "DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE updated + idle < $1", + // numeric arithmetic: an integer sum overflows (an error) for an idle near the type's max. + "DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE updated::numeric + idle < $1", params: [Int(Date().timeIntervalSince1970)] ) } diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index dfb65d1..3f789ca 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -14,6 +14,16 @@ enum MySQLLive { // Serialized: each test sets the connector's global settings. @Suite(.serialized, .enabled(if: MySQLLive.mysqlEnabled, "set MYSQL_TESTS=1 and MYSQL_PORT to run against a server")) struct PerfectSessionMySQLTests { + /// Drops the test table once per run, so setup() creates it with the current schema. + static let freshTable: Void = { + let db = MySQL() + if db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)) { + _ = db.query(statement: "DROP TABLE IF EXISTS `test_sessions`") + } + }() + func getDriver() async -> MySQLSessionDriver { MySQLSessionConnector.host = ProcessInfo.processInfo.environment["MYSQL_HOST"] ?? "localhost" MySQLSessionConnector.username = ProcessInfo.processInfo.environment["MYSQL_USER"] ?? "root" @@ -21,6 +31,7 @@ enum MySQLLive { MySQLSessionConnector.database = ProcessInfo.processInfo.environment["MYSQL_DATABASE"] ?? "perfect_sessions_test" MySQLSessionConnector.port = MySQLLive.port ?? 0 MySQLSessionConnector.table = "test_sessions" + Self.freshTable let driver = MySQLSessionDriver() await driver.setup() return driver @@ -77,6 +88,35 @@ enum MySQLLive { } } + /// The columns were 32-bit: an idle time past Int32.max failed to store, and timestamps + /// stop at 2038. + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 3_000_000_000 + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.idle == 3_000_000_000) + #expect(resumed.created == session.created) + #expect(resumed.updated == session.updated) + await driver.clean() + _ = try await driver.resume(token: session.token) + + // clean() still deletes expired sessions when another's updated + idle overflows BIGINT. + session.idle = Int.max + await driver.save(session) + var expired = await driver.create() + expired.idle = 1 + expired.updated -= 10 + await driver.save(expired) + await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: expired.token) + } + await driver.destroy(token: session.token) + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index fbadee8..bd2ae34 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -12,6 +12,15 @@ enum PostgreSQLLive { // Serialized: each test sets the connector's global settings. @Suite(.serialized, .enabled(if: PostgreSQLLive.pgEnabled, "set PG_TESTS=1 to run against a server")) struct PerfectSessionPostgreSQLTests { + /// Drops the test table once per run, so setup() creates it with the current schema. + static let freshTable: Void = { + let db = PGConnection() + defer { db.close() } + if db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok { + db.exec(statement: #"DROP TABLE IF EXISTS "test_sessions""#).clear() + } + }() + func getDriver() async -> PostgreSQLSessionDriver { PostgreSQLSessionConnector.host = ProcessInfo.processInfo.environment["PG_HOST"] ?? "localhost" PostgreSQLSessionConnector.username = ProcessInfo.processInfo.environment["PG_USER"] ?? "postgres" @@ -19,6 +28,7 @@ enum PostgreSQLLive { PostgreSQLSessionConnector.database = ProcessInfo.processInfo.environment["PG_DATABASE"] ?? "perfect_sessions_test" PostgreSQLSessionConnector.port = Int(ProcessInfo.processInfo.environment["PG_PORT"] ?? "") ?? 5432 PostgreSQLSessionConnector.table = "test_sessions" + Self.freshTable let driver = PostgreSQLSessionDriver() await driver.setup() return driver @@ -74,6 +84,34 @@ enum PostgreSQLLive { } } + /// The columns were 32-bit: an idle time past Int32.max failed to store, timestamps stop at + /// 2038, and clean() failed outright (integer out of range) once any row's updated + idle + /// passed Int32.max. + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 3_000_000_000 + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.idle == 3_000_000_000) + #expect(resumed.created == session.created) + await driver.clean() + _ = try await driver.resume(token: session.token) + + session.idle = Int.max + await driver.save(session) + var expired = await driver.create() + expired.idle = 1 + expired.updated -= 10 + await driver.save(expired) + await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: expired.token) + } + await driver.destroy(token: session.token) + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 4e22ed1..c5d463f 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -72,6 +72,16 @@ enum SQLiteLive { } } + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = Int.max + await driver.save(session) + await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + await driver.destroy(token: session.token) + } + @Test func clean() async { let driver = await getDriver() var session = await driver.create() From 4e258f29e0939bb48bced0175a62a2e56ad7824c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:04:08 -0500 Subject: [PATCH 09/33] PostgreSQL: percent-encode connection URI parts fully 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 --- .../PostgreSQLSessionDriver.swift | 23 ++++++++++++---- .../PerfectSessionPostgreSQLTests.swift | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index cb83be5..131614d 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -11,13 +11,26 @@ public struct PostgreSQLSessionConnector: Sendable { nonisolated(unsafe) public static var port: Int = 5432 private init() {} + /// A libpq connection URI for the connector's settings. public static func connectionString() -> String { - let u = username.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? username - let p = password.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? password - let h = host.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? host - let d = database.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? database - return "postgresql://\(u):\(p)@\(h):\(port)/\(d)" + connectionString(username: username, password: password, host: host, port: port, database: database) } + + /// A libpq connection URI. Each part is percent-encoded except RFC 3986's unreserved + /// characters, so a password containing `@`, `/`, `:`, `?` or `#` can't change the URI's shape. + /// The host may also be a bracketed IPv6 address, or a Unix-socket directory (encoded, as + /// libpq expects). + public static func connectionString(username: String, password: String, host: String, + port: Int, database: String) -> String { + func encode(_ part: String) -> String { + part.addingPercentEncoding(withAllowedCharacters: unreserved) ?? part + } + let h = host.addingPercentEncoding(withAllowedCharacters: unreserved.union(CharacterSet(charactersIn: "[]:"))) ?? host + return "postgresql://\(encode(username)):\(encode(password))@\(h):\(port)/\(encode(database))" + } + + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") } public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index bd2ae34..c16b8e1 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -124,6 +124,18 @@ enum PostgreSQLLive { } } + /// Under trust auth any password is accepted, so this shows the URI still parses. + @Test func passwordWithReservedCharactersConnects() async { + _ = await getDriver() + let saved = PostgreSQLSessionConnector.password + defer { PostgreSQLSessionConnector.password = saved } + PostgreSQLSessionConnector.password = saved.isEmpty ? "p@ss/w:rd?x#1" : saved + guard saved.isEmpty else { return } + let db = PGConnection() + defer { db.close() } + #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, "\(db.errorMessage())") + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async { _ = await getDriver() @@ -140,3 +152,17 @@ enum PostgreSQLLive { let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" #expect(!required || PostgreSQLLive.pgEnabled, "SESSION_REQUIRE_LIVE=1 but PG_TESTS is not 1") } + +/// connectionString() percent-encoded with .urlQueryAllowed, which leaves "/", "@", ":" and "?" +/// alone, so a password containing one broke the URI. +@Test func postgreSQLConnectionStringEncodesReservedCharacters() { + #expect(PostgreSQLSessionConnector.connectionString( + username: "us:er", password: "p@ss/w:rd?x=1&y#z%", host: "db.example.com", port: 5433, + database: "my db/x") == + "postgresql://us%3Aer:p%40ss%2Fw%3Ard%3Fx%3D1%26y%23z%25@db.example.com:5433/my%20db%2Fx") + #expect(PostgreSQLSessionConnector.connectionString( + username: "u", password: "", host: "[::1]", port: 5432, database: "d") == "postgresql://u:@[::1]:5432/d") + #expect(PostgreSQLSessionConnector.connectionString( + username: "u", password: "", host: "/var/run/postgresql", port: 5432, database: "d") == + "postgresql://u:@%2Fvar%2Frun%2Fpostgresql:5432/d") +} From 61d59f4db102c8dc0684cd348522474287b455fc Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:06:17 -0500 Subject: [PATCH 10/33] Every driver's resume() refuses an expired session; optional absolute 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 --- .../MemorySessionDriver.swift | 2 +- .../PerfectSessionCore/PerfectSession.swift | 16 ++++++++-- .../PerfectSessionCore/SessionConfig.swift | 4 +++ .../MongoDBSessionDriver.swift | 6 ++-- .../MySQLSessionDriver.swift | 2 +- .../PostgreSQLSessionDriver.swift | 1 + .../RedisSessionDriver.swift | 2 +- .../SQLiteSessionDriver.swift | 2 +- .../PerfectSessionCoreTests.swift | 25 +++++++++++++++ .../PerfectSessionMongoDBTests.swift | 27 ++++++++++++++-- .../PerfectSessionMySQLTests.swift | 31 ++++++++++++++++--- .../PerfectSessionPostgreSQLTests.swift | 30 +++++++++++++++--- .../PerfectSessionSQLiteTests.swift | 30 ++++++++++++++++-- 13 files changed, 155 insertions(+), 23 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index c4a07d9..f852c49 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -21,7 +21,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { public func resume(token: String) async throws -> PerfectSession { let found = lock.withLock { sessions[token] } - guard var session = found else { throw InvalidSessionError() } + guard var session = found, !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" return session } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index 33da402..188af4c 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -51,10 +51,20 @@ public struct PerfectSession: @unchecked Sendable { updated = Int(Date().timeIntervalSince1970) } - /// Returns false if the session has expired or IP/UA locks fail. + /// True once the session has been idle for `idle` seconds, or (when **absoluteTimeout** is + /// positive) once that many seconds have passed since it was created, however active it was. + /// Every driver's `resume` refuses an expired session. + public func isExpired(at now: Int = Int(Date().timeIntervalSince1970), + absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Bool { + if expires <= now { return true } + guard absoluteTimeout > 0 else { return false } + let (end, overflow) = created.addingReportingOverflow(absoluteTimeout) + return !overflow && end <= now + } + + /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "") -> Bool { - let now = Int(Date().timeIntervalSince1970) - guard expires > now else { return false } + guard !isExpired() else { return false } if SessionConfig.IPAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } if SessionConfig.userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } return true diff --git a/Sources/PerfectSessionCore/SessionConfig.swift b/Sources/PerfectSessionCore/SessionConfig.swift index 9eec73d..163a0bf 100644 --- a/Sources/PerfectSessionCore/SessionConfig.swift +++ b/Sources/PerfectSessionCore/SessionConfig.swift @@ -13,7 +13,11 @@ public struct SessionConfig { nonisolated(unsafe) public static var cookieSecure = false nonisolated(unsafe) public static var cookieHTTPOnly = true nonisolated(unsafe) public static var cookieSameSite: CookieSameSite = .strict + /// Seconds a session lasts without being saved (each save renews it). nonisolated(unsafe) public static var idle = 86400 + /// Seconds a session lasts from its creation however active it is, or 0 (the default) for no + /// limit. Checked by `PerfectSession.isExpired()`, `isValid()` and every driver's `resume`. + nonisolated(unsafe) public static var absoluteTimeout = 0 nonisolated(unsafe) public static var userAgentLock = false nonisolated(unsafe) public static var IPAddressLock = false nonisolated(unsafe) public static var healthCheckRoute = "/healthcheck" diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index 38de5d6..729d735 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -21,8 +21,7 @@ public struct MongoDBSessionConnector: Sendable { /// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see /// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB /// deletes expired sessions on its own; `clean()` still deletes them immediately for callers -/// that rely on it. Like the other drivers, `resume` returns expired sessions too: callers -/// check `PerfectSession.isValid()`. +/// that rely on it. Like the other drivers, `resume` refuses an expired session. /// /// `save` only updates an existing session, so a save racing a `destroy` (logout) can't bring /// the session back. `setup()` also deletes documents left by the archived Perfect-Session-MongoDB @@ -100,7 +99,8 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"]) throw InvalidSessionError() } - guard var session = found?.session else { + // The TTL monitor only runs about once a minute, so check expiry here too. + guard var session = found?.session, !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 90cd470..7e3b153 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -70,7 +70,7 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { s.useragent = row[7] as? String ?? "" found = s } - guard var s = found else { throw InvalidSessionError() } + guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" return s } diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index 131614d..3b397a4 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -86,6 +86,7 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { if let d = result.getFieldString(tupleIndex: 0, fieldIndex: 5) { s.fromjson(d) } s.ipaddress = result.getFieldString(tupleIndex: 0, fieldIndex: 6) ?? "" s.useragent = result.getFieldString(tupleIndex: 0, fieldIndex: 7) ?? "" + guard !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" return s } diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 2b25b91..5ba7a76 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -56,7 +56,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { throw InvalidSessionError() } guard let json = response.string, !json.isEmpty, - var session = decode(token: token, json: json) else { + var session = decode(token: token, json: json), !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 2425f4f..3928cfe 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -85,7 +85,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { found = s } ) - guard var s = found else { throw InvalidSessionError() } + guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" return s } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 3bcaa11..54e46d2 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -108,6 +108,31 @@ import Foundation _ = try await driver.resume(token: stored.token) } + @Test func absoluteTimeout() { + var s = PerfectSession() + let now = Int(Date().timeIntervalSince1970) + s.created = now - 100 + s.updated = now + s.idle = 3600 + #expect(!s.isExpired(at: now, absoluteTimeout: 0)) + #expect(!s.isExpired(at: now, absoluteTimeout: 101)) + #expect(s.isExpired(at: now, absoluteTimeout: 100)) + #expect(!s.isExpired(at: now, absoluteTimeout: Int.max)) + #expect(s.isExpired(at: now + 3600, absoluteTimeout: 0)) + } + + /// resume() used to return an expired session, leaving the check to the caller. + @Test func memoryResumeRejectsExpired() async { + let driver = MemorySessionDriver() + var session = await driver.create() + session.idle = 1 + session.updated = Int(Date().timeIntervalSince1970) - 10 + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + @Test func sessionIsValidIPLock() { var s = PerfectSession() s.idle = 86400 diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 70151a8..777a652 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -194,6 +194,20 @@ enum MongoDBLive { await driver.destroy(token: session.token) } + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = try getDriver() + var session = await driver.create() + session.idle = 1 + session.updated = Int(Date().timeIntervalSince1970) - 10 + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + await driver.destroy(token: session.token) + } + @Test func destroyRemovesSession() async throws { let driver = try getDriver() let session = await driver.create() @@ -236,10 +250,17 @@ enum MongoDBLive { expired.idle = 1 await driver.save(expired) let live = await driver.create() - await driver.clean() - await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: expired.token) + // Counted directly: resume() refuses an expired session whether or not clean() deleted it. + let token = expired.token + func stored() async throws -> Int { + try await driver.pool.withClient { client in + try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test") + .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#)) + } } + #expect(try await stored() == 1) + await driver.clean() + #expect(try await stored() == 0) _ = try await driver.resume(token: live.token) await driver.destroy(token: live.token) } diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 3f789ca..cb6b97f 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -24,6 +24,18 @@ enum MySQLLive { } }() + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) -> Bool { + let db = MySQL() + guard db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)), + db.query(statement: "SELECT 1 FROM `test_sessions` WHERE token = '\(token)'"), + let results = db.storeResults() else { return false } + return results.numRows() > 0 + } + func getDriver() async -> MySQLSessionDriver { MySQLSessionConnector.host = ProcessInfo.processInfo.environment["MYSQL_HOST"] ?? "localhost" MySQLSessionConnector.username = ProcessInfo.processInfo.environment["MYSQL_USER"] ?? "root" @@ -111,8 +123,20 @@ enum MySQLLive { await driver.save(expired) await driver.clean() #expect(try await driver.resume(token: session.token).idle == Int.max) + #expect(!isStored(expired.token)) + await driver.destroy(token: session.token) + } + + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 1 + session.updated = Int(Date().timeIntervalSince1970) - 10 + await driver.save(session) await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: expired.token) + _ = try await driver.resume(token: session.token) } await driver.destroy(token: session.token) } @@ -123,10 +147,9 @@ enum MySQLLive { session.idle = 1 session.updated = Int(Date().timeIntervalSince1970) - 10 await driver.save(session) + #expect(isStored(session.token)) await driver.clean() - await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: session.token) - } + #expect(!(isStored(session.token))) } /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index c16b8e1..7133281 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -21,6 +21,17 @@ enum PostgreSQLLive { } }() + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) -> Bool { + let db = PGConnection() + defer { db.close() } + guard db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok else { return false } + let result = db.exec(statement: #"SELECT 1 FROM "test_sessions" WHERE token = $1"#, params: [token]) + defer { result.clear() } + return result.numTuples() > 0 + } + func getDriver() async -> PostgreSQLSessionDriver { PostgreSQLSessionConnector.host = ProcessInfo.processInfo.environment["PG_HOST"] ?? "localhost" PostgreSQLSessionConnector.username = ProcessInfo.processInfo.environment["PG_USER"] ?? "postgres" @@ -106,8 +117,20 @@ enum PostgreSQLLive { await driver.save(expired) await driver.clean() #expect(try await driver.resume(token: session.token).idle == Int.max) + #expect(!isStored(expired.token)) + await driver.destroy(token: session.token) + } + + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 1 + session.updated = Int(Date().timeIntervalSince1970) - 10 + await driver.save(session) await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: expired.token) + _ = try await driver.resume(token: session.token) } await driver.destroy(token: session.token) } @@ -118,10 +141,9 @@ enum PostgreSQLLive { session.idle = 1 session.updated = Int(Date().timeIntervalSince1970) - 10 await driver.save(session) + #expect(isStored(session.token)) await driver.clean() - await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: session.token) - } + #expect(!(isStored(session.token))) } /// Under trust auth any password is accepted, so this shows the URI still parses. diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index c5d463f..cd6b7f3 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -2,6 +2,7 @@ import Testing import Foundation @testable import PerfectSessionSQLite import PerfectSessionCore +import PerfectSQLite /// Live-test settings, outside the suite so its traits can refer to them. enum SQLiteLive { @@ -11,6 +12,18 @@ enum SQLiteLive { // Serialized: each test sets the connector's global settings. @Suite(.serialized, .enabled(if: SQLiteLive.sqliteEnabled, "set SQLITE_TESTS=1 to run")) struct PerfectSessionSQLiteTests { + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) throws -> Bool { + let db = try SQLite(SQLiteSessionConnector.databasePath) + defer { db.close() } + var found = false + try db.forEachRow(statement: #"SELECT 1 FROM "test_sessions" WHERE token = ?"#, + doBindings: { try $0.bind(position: 1, token) }, + handleRow: { _, _ in found = true }) + return found + } + func getDriver() async -> SQLiteSessionDriver { let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("perfect_session_test_\(Int.random(in: 1..<999999)).db") @@ -82,16 +95,29 @@ enum SQLiteLive { await driver.destroy(token: session.token) } - @Test func clean() async { + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { let driver = await getDriver() var session = await driver.create() session.idle = 1 session.updated = Int(Date().timeIntervalSince1970) - 10 await driver.save(session) - await driver.clean() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) } + await driver.destroy(token: session.token) + } + + @Test func clean() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 1 + session.updated = Int(Date().timeIntervalSince1970) - 10 + await driver.save(session) + #expect(try isStored(session.token)) + await driver.clean() + #expect(!(try isStored(session.token))) } @Test func dataRoundTrip() async throws { From ac62cf7beb0a8946dcca8f1546c28c9014cce7d2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:11:19 -0500 Subject: [PATCH 11/33] Generate session and CSRF tokens with SystemRandomNumberGenerator 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 --- .../MemorySessionDriver.swift | 2 +- .../PerfectSessionCore/PerfectSession.swift | 26 ++++++++++- .../MongoDBSessionDriver.swift | 2 +- .../MySQLSessionDriver.swift | 2 +- .../PostgreSQLSessionDriver.swift | 2 +- .../RedisSessionDriver.swift | 2 +- .../SQLiteSessionDriver.swift | 2 +- .../PerfectSessionCoreTests.swift | 44 +++++++++++++++++++ 8 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index f852c49..06962eb 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -9,7 +9,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index 188af4c..aa1f592 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -70,9 +70,33 @@ public struct PerfectSession: @unchecked Sendable { return true } + /// A new session or CSRF token: 32 bytes (256 bits) from the system's secure random number + /// generator, as 64 lowercase hex digits. + /// + /// Not `UUID()`: on Linux, Foundation's UUID reads /dev/urandom and ignores a failed open, + /// so in a process out of file descriptors every UUID is 00000000-0000-4000-8000-000000000000. + /// `SystemRandomNumberGenerator` uses getrandom(2) or arc4random_buf(3) and never returns + /// unfilled bytes. + public static func newToken() -> String { + var generator = SystemRandomNumberGenerator() + let hex = Array("0123456789abcdef".utf8) + var digits = [UInt8]() + digits.reserveCapacity(64) + for _ in 0..<4 { + var word = generator.next() + for _ in 0..<8 { + let byte = UInt8(truncatingIfNeeded: word) + digits.append(hex[Int(byte >> 4)]) + digits.append(hex[Int(byte & 0x0f)]) + word >>= 8 + } + } + return String(decoding: digits, as: UTF8.self) + } + public mutating func setCSRF() { if (data["csrf"] as? String ?? "").isEmpty { - data["csrf"] = UUID().uuidString + data["csrf"] = PerfectSession.newToken() } } } diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index 729d735..dca1d6c 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -75,7 +75,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 7e3b153..98e8c1e 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -35,7 +35,7 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index 3b397a4..ad30260 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -55,7 +55,7 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 5ba7a76..452fdb4 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -26,7 +26,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 3928cfe..524594a 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -38,7 +38,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 54e46d2..04080cc 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -1,6 +1,11 @@ import Testing import Foundation @testable import PerfectSessionCore +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @Suite struct PerfectSessionCoreTests { @@ -157,6 +162,45 @@ import Foundation #expect(!s.isValid(useragent: "Chrome/120")) } + // MARK: - Tokens + + @Test func tokensAre256BitHex() async { + let driver = MemorySessionDriver() + var seen = Set() + for _ in 0..<500 { + let session = await driver.create() + for token in [session.token, session.data["csrf"] as? String ?? ""] { + #expect(token.count == 64) + #expect(token.allSatisfy { "0123456789abcdef".contains($0) }) + seen.insert(token) + } + } + #expect(seen.count == 1000) + } + + /// On Linux, Foundation's UUID() reads /dev/urandom and ignores a failed open(), so with no + /// file descriptor left every UUID was 00000000-0000-4000-8000-000000000000: every new + /// session got the same token. Runs in a child process, which it starves of descriptors. + @Test func tokensStayRandomWithoutFileDescriptors() async { + await #expect(processExitsWith: .success) { + #if os(Linux) + let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue) + #else + let resource = RLIMIT_NOFILE + #endif + var limit = rlimit() + getrlimit(resource, &limit) + limit.rlim_cur = 64 + setrlimit(resource, &limit) + while open("/dev/null", O_RDONLY) >= 0 {} + let driver = MemorySessionDriver() + let first = await driver.create(), second = await driver.create() + let distinct = first.token != second.token + && first.data["csrf"] as? String != second.data["csrf"] as? String + exit(distinct ? EXIT_SUCCESS : EXIT_FAILURE) + } + } + // MARK: - MemorySessionDriver @Test func memoryCreate() async { From 0428231ef671afaf4d938c37219bcdfd3af81daa Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:12:24 -0500 Subject: [PATCH 12/33] Add SessionDriver.regenerate() against session fixation 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 --- .../PerfectSessionCore/SessionDriver.swift | 21 ++++++++++++++++++ .../PerfectSessionCoreTests.swift | 22 +++++++++++++++++++ .../PerfectSessionMongoDBTests.swift | 19 ++++++++++++++++ .../PerfectSessionMySQLTests.swift | 19 ++++++++++++++++ .../PerfectSessionPostgreSQLTests.swift | 19 ++++++++++++++++ .../PerfectSessionRedisTests.swift | 19 ++++++++++++++++ .../PerfectSessionSQLiteTests.swift | 19 ++++++++++++++++ 7 files changed, 138 insertions(+) diff --git a/Sources/PerfectSessionCore/SessionDriver.swift b/Sources/PerfectSessionCore/SessionDriver.swift index ba68861..808e1aa 100644 --- a/Sources/PerfectSessionCore/SessionDriver.swift +++ b/Sources/PerfectSessionCore/SessionDriver.swift @@ -14,9 +14,30 @@ public protocol SessionDriver: Sendable { func destroy(token: String) async func clean() async func setup() async + /// Moves a session to a new token. See the default implementation. + func regenerate(_ session: PerfectSession) async -> PerfectSession } extension SessionDriver { public func clean() async {} public func setup() async {} + + /// Moves **session**'s user, data and idle time to a newly created session with a new token + /// and a new CSRF token, then destroys the old session. Call it whenever the user signs in, + /// signs out to a guest session, or gains privileges, and send the client the new token: + /// a token an attacker planted or saw beforehand (session fixation) then no longer works. + /// + /// The new session keeps the old one's IP address and user agent. If the backend can't store + /// it, the returned session isn't stored (as with `create`), and the old one is destroyed. + public func regenerate(_ session: PerfectSession) async -> PerfectSession { + var fresh = await create(ipaddress: session.ipaddress, useragent: session.useragent) + let csrf = fresh.data["csrf"] + fresh.userid = session.userid + fresh.idle = session.idle + fresh.data = session.data + fresh.data["csrf"] = csrf + await save(fresh) + await destroy(token: session.token) + return fresh + } } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 04080cc..a760604 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -267,6 +267,28 @@ import Darwin } } + /// There was no way to move a session to a new token, so a token fixed before sign-in + /// stayed valid after it. + @Test func memoryRegenerate() async throws { + let driver = MemorySessionDriver() + var session = await driver.create(ipaddress: "10.0.0.9", useragent: "UA") + session.userid = "alice" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + #expect(fresh.data["csrf"] as? String != session.data["csrf"] as? String) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "alice") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.ipaddress == "10.0.0.9") + #expect(resumed.useragent == "UA") + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 777a652..3443d51 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -265,6 +265,25 @@ enum MongoDBLive { await driver.destroy(token: live.token) } + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = try getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async throws { let driver = try getDriver() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index cb6b97f..4c2d2ab 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -152,6 +152,25 @@ enum MySQLLive { #expect(!(isStored(session.token))) } + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async { _ = await getDriver() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 7133281..7a6dc8a 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -158,6 +158,25 @@ enum PostgreSQLLive { #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, "\(db.errorMessage())") } + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async { _ = await getDriver() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 70a54ba..f47aff6 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -153,6 +153,25 @@ enum RedisLive { await driver.destroy(token: session.token) } + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async throws { _ = getDriver() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index cd6b7f3..0cff7b4 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -120,6 +120,25 @@ enum SQLiteLive { #expect(!(try isStored(session.token))) } + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + @Test func dataRoundTrip() async throws { let driver = await getDriver() var session = await driver.create() From 89b512006ce146d16c988af29a55db33c18569fa Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:13:12 -0500 Subject: [PATCH 13/33] Add SessionCookie: Set-Cookie values from SessionConfig's cookie settings 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 --- .../PerfectSessionCore/SessionCookie.swift | 69 +++++++++++++++++++ .../PerfectSessionCoreTests.swift | 63 +++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 Sources/PerfectSessionCore/SessionCookie.swift diff --git a/Sources/PerfectSessionCore/SessionCookie.swift b/Sources/PerfectSessionCore/SessionCookie.swift new file mode 100644 index 0000000..0ee67cb --- /dev/null +++ b/Sources/PerfectSessionCore/SessionCookie.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Builds `Set-Cookie` header values for the session token from `SessionConfig`'s cookie +/// settings, so middleware doesn't assemble the attributes by hand. +public enum SessionCookie { + + /// The `Set-Cookie` value that stores **token**: `name=token; Path=…; [Domain=…;] HttpOnly; + /// Secure; SameSite=…`, from `SessionConfig`. It has no `Expires`/`Max-Age`, so the browser + /// drops it when it closes; the server enforces `idle` and `absoluteTimeout` itself. + /// + /// `SameSite=None` always adds `Secure` (browsers reject it otherwise), and so does a name + /// starting with `__Secure-` or `__Host-`. Returns nil, rather than a header a browser would + /// misread, if the name isn't a valid cookie name, the token or path holds a character a cookie + /// can't (`;`, `,`, whitespace, quotes, a backslash or a control character), the domain isn't + /// a plain host name, or a `__Host-` cookie has a domain or a path other than `/`. + public static func setCookie(token: String) -> String? { + build(value: token, extra: nil) + } + + /// The `Set-Cookie` value that deletes the session cookie (on sign-out), with the same + /// attributes so the browser matches it. + public static func expiredCookie() -> String? { + build(value: "", extra: "Max-Age=0") + } + + static func build(value: String, extra: String?) -> String? { + let name = SessionConfig.name + let path = SessionConfig.cookiePath + let domain = SessionConfig.cookieDomain + guard isToken(name), value.unicodeScalars.allSatisfy(isCookieOctet), + !path.isEmpty, path.unicodeScalars.allSatisfy(isPathCharacter), + domain.isEmpty || isHostName(domain) else { return nil } + let hostPrefixed = name.hasPrefix("__Host-") + if hostPrefixed && (!domain.isEmpty || path != "/") { return nil } + let secure = SessionConfig.cookieSecure || SessionConfig.cookieSameSite == .none + || hostPrefixed || name.hasPrefix("__Secure-") + var parts = ["\(name)=\(value)", "Path=\(path)"] + if !domain.isEmpty { parts.append("Domain=\(domain)") } + if let extra { parts.append(extra) } + if SessionConfig.cookieHTTPOnly { parts.append("HttpOnly") } + if secure { parts.append("Secure") } + parts.append("SameSite=\(SessionConfig.cookieSameSite.rawValue)") + return parts.joined(separator: "; ") + } + + /// RFC 7230 `token`: visible ASCII except separators. + static func isToken(_ s: String) -> Bool { + !s.isEmpty && s.unicodeScalars.allSatisfy { c in + c.value > 0x20 && c.value < 0x7f && !"()<>@,;:\\\"/[]?={}".unicodeScalars.contains(c) + } + } + + /// RFC 6265 `cookie-octet`. + static func isCookieOctet(_ c: Unicode.Scalar) -> Bool { + c.value == 0x21 || (0x23...0x2b).contains(c.value) || (0x2d...0x3a).contains(c.value) + || (0x3c...0x5b).contains(c.value) || (0x5d...0x7e).contains(c.value) + } + + /// RFC 6265 `path-value` (any CHAR but controls and `;`), also without spaces or commas. + static func isPathCharacter(_ c: Unicode.Scalar) -> Bool { + c.value > 0x20 && c.value < 0x7f && c != ";" && c != "," + } + + static func isHostName(_ s: String) -> Bool { + s.unicodeScalars.allSatisfy { c in + ("a"..."z").contains(c) || ("A"..."Z").contains(c) || ("0"..."9").contains(c) || c == "-" || c == "." + } + } +} diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index a760604..debc9b7 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -389,3 +389,66 @@ import Darwin #expect(CookieSameSite.none.rawValue == "None") } } + +/// Serialized because each test changes SessionConfig's cookie settings, which no other test reads. +@Suite(.serialized) struct SessionCookieTests { + func withCookieConfig(name: String = "PerfectSession", domain: String = "", path: String = "/", + secure: Bool = false, httpOnly: Bool = true, sameSite: CookieSameSite = .strict, + _ body: () -> Void) { + let saved = (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) + defer { + (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) = saved + } + (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) = + (name, domain, path, secure, httpOnly, sameSite) + body() + } + + @Test func defaults() { + withCookieConfig { + #expect(SessionCookie.setCookie(token: "abc123") == "PerfectSession=abc123; Path=/; HttpOnly; SameSite=Strict") + #expect(SessionCookie.expiredCookie() == "PerfectSession=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict") + } + } + + @Test func allAttributes() { + withCookieConfig(domain: "example.com", path: "/app", secure: true, httpOnly: false, sameSite: .lax) { + #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/app; Domain=example.com; Secure; SameSite=Lax") + } + } + + @Test func sameSiteNoneAndPrefixesForceSecure() { + withCookieConfig(sameSite: .none) { + #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/; HttpOnly; Secure; SameSite=None") + } + withCookieConfig(name: "__Host-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__Host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } + withCookieConfig(name: "__Host-sid", domain: "example.com") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + withCookieConfig(name: "__Host-sid", path: "/app") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + } + + /// Settings or a token containing ";" or CR/LF would otherwise add attributes or headers. + @Test func refusesValuesThatWouldInjectAttributes() { + withCookieConfig { + for token in ["a;Domain=evil.com", "a\r\nSet-Cookie: x=1", "a b", "a,b", "\"a\"", "é"] { + #expect(SessionCookie.setCookie(token: token) == nil, "\(token.debugDescription)") + } + } + withCookieConfig(name: "sid;Secure") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(name: "") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(domain: "example.com; SameSite=None") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(path: "/;HttpOnly") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(path: "/a\nb") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig { + #expect(SessionCookie.setCookie(token: PerfectSession.newToken()) != nil) + } + } +} From fc8424e273405b354b89d596ac3d9d1e38a459de Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:15:09 -0600 Subject: [PATCH 14/33] Tests: injection probes for the SQL and MongoDB backends 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 --- .../PerfectSessionMongoDBTests.swift | 29 +++++++++++++++++++ .../PerfectSessionMySQLTests.swift | 20 +++++++++++++ .../PerfectSessionPostgreSQLTests.swift | 20 +++++++++++++ .../PerfectSessionSQLiteTests.swift | 20 +++++++++++++ 4 files changed, 89 insertions(+) diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 3443d51..f4dcf30 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -284,6 +284,35 @@ enum MongoDBLive { await driver.destroy(token: fresh.token) } + /// Keys MongoDB treats specially in queries and updates are stored as data, at any depth. + @Test func operatorAndDottedKeysRoundTrip() async throws { + let driver = try getDriver() + var session = await driver.create() + let keys = ["$where", "$set", "$", "a.b", ".", "$x.y", "_id", "expiresAt"] + for key in keys { session.data[key] = key } + session.data["nested"] = ["$gt": 1, "c.d": ["$ne": "x"]] + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + for key in keys { #expect(resumed.data[key] as? String == key, "\(key)") } + let nested = resumed.data["nested"] as? [String: Any] + #expect(nested?["$gt"] as? Int == 1) + #expect((nested?["c.d"] as? [String: Any])?["$ne"] as? String == "x") + await driver.destroy(token: session.token) + } + + /// The token is matched as a string, so one that looks like an operator matches nothing. + @Test(arguments: [#"{"$ne": ""}"#, "$ne", #"{"$gt": ""}"#, ".*", "\u{0}"]) + func operatorLikeTokensMatchNothing(_ token: String) async throws { + let driver = try getDriver() + let other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + _ = try await driver.resume(token: other.token) + await driver.destroy(token: other.token) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async throws { let driver = try getDriver() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 4c2d2ab..0987629 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -152,6 +152,26 @@ enum MySQLLive { #expect(!(isStored(session.token))) } + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 7a6dc8a..932e40b 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -158,6 +158,26 @@ enum PostgreSQLLive { #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, "\(db.errorMessage())") } + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 0cff7b4..dc6fa01 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -120,6 +120,26 @@ enum SQLiteLive { #expect(!(try isStored(session.token))) } + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From 5d54233f1bded8e0e3951cf70a99cd219c23a362 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:21:34 -0600 Subject: [PATCH 15/33] Concurrency tests; checks take explicit config so tests don't race 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 --- Sources/PerfectSessionCore/CSRFSecurity.swift | 20 ++-- .../PerfectSessionCore/PerfectSession.swift | 11 +- .../PerfectSessionCoreTests.swift | 102 ++++++++++++------ .../PerfectSessionMongoDBTests.swift | 25 +++++ .../PerfectSessionMySQLTests.swift | 25 +++++ .../PerfectSessionPostgreSQLTests.swift | 25 +++++ .../PerfectSessionRedisTests.swift | 25 +++++ .../PerfectSessionSQLiteTests.swift | 25 +++++ 8 files changed, 209 insertions(+), 49 deletions(-) diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index 1839a99..cacac70 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -4,14 +4,13 @@ import Foundation /// values from the request and passes them as strings to these helpers. public struct CSRFSecurity: Sendable { - public static func isValid(origin: String, host: String) -> Bool { + /// True if **origin** (scheme stripped) is in **acceptableHostnames** (by default + /// `SessionConfig.CSRF.acceptableHostnames`) or equals **host**. + public static func isValid(origin: String, host: String, + acceptableHostnames: [String] = SessionConfig.CSRF.acceptableHostnames) -> Bool { let cleanOrigin = stripScheme(origin) if cleanOrigin.isEmpty { return false } - if !SessionConfig.CSRF.acceptableHostnames.isEmpty { - for check in SessionConfig.CSRF.acceptableHostnames { - if check == cleanOrigin { return true } - } - } + if acceptableHostnames.contains(cleanOrigin) { return true } let cleanHost = stripScheme(host) if cleanHost.isEmpty { return false } return cleanHost == cleanOrigin @@ -27,15 +26,16 @@ public struct CSRFSecurity: Sendable { /// Validates CORS origin against SessionConfig.CORS.acceptableHostnames. public struct CORSSecurity: Sendable { - /// True if **origin** matches an entry in `SessionConfig.CORS.acceptableHostnames`, ignoring + /// True if **origin** matches an entry in **config**'s `acceptableHostnames` (by default + /// `SessionConfig.CORS`'s), ignoring /// case. `"*"` accepts every origin; in any other entry each `*` matches any run of /// characters, and the rest must match in full (`"https://*.example.com"`). - public static func isAcceptable(origin: String) -> Bool { - guard SessionConfig.CORS.enabled, !SessionConfig.CORS.acceptableHostnames.isEmpty else { + public static func isAcceptable(origin: String, config: SessionConfig.CORSconfig = SessionConfig.CORS) -> Bool { + guard config.enabled, !config.acceptableHostnames.isEmpty else { return false } let lower = origin.lowercased() - for pattern in SessionConfig.CORS.acceptableHostnames { + for pattern in config.acceptableHostnames { if pattern == "*" || matches(lower, pattern: pattern.lowercased()) { return true } } return false diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index aa1f592..c86af96 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -62,11 +62,14 @@ public struct PerfectSession: @unchecked Sendable { return !overflow && end <= now } - /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. - public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "") -> Bool { + /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. The locks + /// default to `SessionConfig.IPAddressLock` and `SessionConfig.userAgentLock`. + public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "", + ipAddressLock: Bool = SessionConfig.IPAddressLock, + userAgentLock: Bool = SessionConfig.userAgentLock) -> Bool { guard !isExpired() else { return false } - if SessionConfig.IPAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } - if SessionConfig.userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } + if ipAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } + if userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } return true } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index debc9b7..3f573cc 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -7,6 +7,16 @@ import Glibc import Darwin #endif +/// Whether the ThreadSanitizer runtime is loaded. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() + @Suite struct PerfectSessionCoreTests { @Test func sessionInit() { @@ -143,11 +153,9 @@ import Darwin s.idle = 86400 s.updated = Int(Date().timeIntervalSince1970) s.ipaddress = "10.0.0.1" - let prev = SessionConfig.IPAddressLock - SessionConfig.IPAddressLock = true - defer { SessionConfig.IPAddressLock = prev } - #expect(s.isValid(ipaddress: "10.0.0.1")) - #expect(!s.isValid(ipaddress: "10.0.0.2")) + #expect(s.isValid(ipaddress: "10.0.0.1", ipAddressLock: true)) + #expect(!s.isValid(ipaddress: "10.0.0.2", ipAddressLock: true)) + #expect(s.isValid(ipaddress: "10.0.0.2", ipAddressLock: false)) } @Test func sessionIsValidUALock() { @@ -155,11 +163,9 @@ import Darwin s.idle = 86400 s.updated = Int(Date().timeIntervalSince1970) s.useragent = "Safari/17" - let prev = SessionConfig.userAgentLock - SessionConfig.userAgentLock = true - defer { SessionConfig.userAgentLock = prev } - #expect(s.isValid(useragent: "Safari/17")) - #expect(!s.isValid(useragent: "Chrome/120")) + #expect(s.isValid(useragent: "Safari/17", userAgentLock: true)) + #expect(!s.isValid(useragent: "Chrome/120", userAgentLock: true)) + #expect(s.isValid(useragent: "Chrome/120", userAgentLock: false)) } // MARK: - Tokens @@ -181,7 +187,9 @@ import Darwin /// On Linux, Foundation's UUID() reads /dev/urandom and ignores a failed open(), so with no /// file descriptor left every UUID was 00000000-0000-4000-8000-000000000000: every new /// session got the same token. Runs in a child process, which it starves of descriptors. - @Test func tokensStayRandomWithoutFileDescriptors() async { + /// (Skipped under ThreadSanitizer, whose runtime aborts a process with no descriptors left.) + @Test(.disabled(if: underThreadSanitizer, "ThreadSanitizer needs file descriptors")) + func tokensStayRandomWithoutFileDescriptors() async { await #expect(processExitsWith: .success) { #if os(Linux) let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue) @@ -289,6 +297,31 @@ import Darwin #expect(resumed.useragent == "UA") } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = MemorySessionDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() @@ -348,11 +381,8 @@ import Darwin } @Test func csrfSecurityAcceptableHostnames() { - let prev = SessionConfig.CSRF.acceptableHostnames - SessionConfig.CSRF.acceptableHostnames = ["trusted.com"] - defer { SessionConfig.CSRF.acceptableHostnames = prev } - #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com")) - #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com")) + #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com", acceptableHostnames: ["trusted.com"])) + #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com", acceptableHostnames: ["trusted.com"])) } // MARK: - CORSSecurity @@ -360,25 +390,27 @@ import Darwin /// A wildcard pattern used to accept any origin that matched its prefix OR its suffix, so /// "https://*.example.com" accepted every https origin. @Test func corsWildcardNeedsPrefixAndSuffix() { - let prev = SessionConfig.CORS - defer { SessionConfig.CORS = prev } - SessionConfig.CORS.enabled = true - SessionConfig.CORS.acceptableHostnames = ["https://*.example.com", "http://localhost:*"] - #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com")) - #expect(CORSSecurity.isAcceptable(origin: "https://A.Example.com")) - #expect(CORSSecurity.isAcceptable(origin: "http://localhost:8080")) - #expect(!CORSSecurity.isAcceptable(origin: "https://evil.com")) - #expect(!CORSSecurity.isAcceptable(origin: "https://example.com.evil.com")) - #expect(!CORSSecurity.isAcceptable(origin: "http://app.example.com")) - #expect(!CORSSecurity.isAcceptable(origin: "https://localhost:8080")) - - SessionConfig.CORS.acceptableHostnames = ["*.example.com"] - #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com")) - #expect(!CORSSecurity.isAcceptable(origin: "https://example.org")) - - SessionConfig.CORS.acceptableHostnames = ["https://Exact.example.com"] - #expect(CORSSecurity.isAcceptable(origin: "https://exact.example.com")) - #expect(!CORSSecurity.isAcceptable(origin: "https://other.example.com")) + var config = SessionConfig.CORSconfig() + config.enabled = true + config.acceptableHostnames = ["https://*.example.com", "http://localhost:*"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config)) + #expect(CORSSecurity.isAcceptable(origin: "https://A.Example.com", config: config)) + #expect(CORSSecurity.isAcceptable(origin: "http://localhost:8080", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://evil.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.com.evil.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "http://app.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://localhost:8080", config: config)) + + config.acceptableHostnames = ["*.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.org", config: config)) + + config.acceptableHostnames = ["https://Exact.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://other.example.com", config: config)) + + config.enabled = false + #expect(!CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config)) } // MARK: - CookieSameSite diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index f4dcf30..064ad1f 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -265,6 +265,31 @@ enum MongoDBLive { await driver.destroy(token: live.token) } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = try getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = try getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 0987629..1be2416 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -172,6 +172,31 @@ enum MySQLLive { await driver.destroy(token: other.token) } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 932e40b..250fcd2 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -178,6 +178,31 @@ enum PostgreSQLLive { await driver.destroy(token: other.token) } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index f47aff6..779aea4 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -153,6 +153,31 @@ enum RedisLive { await driver.destroy(token: session.token) } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index dc6fa01..b62aa60 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -140,6 +140,31 @@ enum SQLiteLive { await driver.destroy(token: other.token) } + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From 86ec91730ec93bb0a74bb78b541f1a7622bb790d Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 08:21:54 -0600 Subject: [PATCH 16/33] Drop @unchecked from the stateless SQL drivers' Sendable 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 --- Sources/PerfectSessionCore/MemorySessionDriver.swift | 1 + Sources/PerfectSessionCore/PerfectSession.swift | 6 +++++- Sources/PerfectSessionMySQL/MySQLSessionDriver.swift | 2 +- .../PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift | 2 +- Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index 06962eb..161bcdf 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -1,6 +1,7 @@ import Foundation /// Thread-safe in-memory session store. Useful for development and testing. +/// `@unchecked Sendable`: every access to `sessions` holds `lock`. public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { private var sessions = [String: PerfectSession]() private let lock = NSLock() diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index c86af96..a2377f3 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -1,6 +1,10 @@ import Foundation -/// A session token/data container. @unchecked Sendable because data:[String:Any] is JSON-safe. +/// A session: its token, user, timestamps and data. +/// +/// `@unchecked Sendable` because `data` is `[String: Any]`. That holds only while `data` holds JSON +/// values (see `data`), which are value types or immutable Foundation objects; don't put a +/// mutable reference type in it. public struct PerfectSession: @unchecked Sendable { public var token = "" public var userid = "" diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 98e8c1e..d5c0206 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -13,7 +13,7 @@ public struct MySQLSessionConnector: Sendable { private init() {} } -public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { +public final class MySQLSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index ad30260..7173425 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -33,7 +33,7 @@ public struct PostgreSQLSessionConnector: Sendable { charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") } -public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { +public final class PostgreSQLSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 524594a..76cd022 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -11,7 +11,7 @@ public struct SQLiteSessionConnector: Sendable { private init() {} } -public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { +public final class SQLiteSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { From 02456536ad9599ae5f99ebbbd4b5f5b19ebb5e6c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:22:47 -0500 Subject: [PATCH 17/33] MySQL: don't trap on an out-of-range port 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 --- .../PerfectSessionMySQL/MySQLSessionDriver.swift | 7 ++++++- .../PerfectSessionMySQLTests.swift | 14 +++++++++++++- .../PerfectSessionPostgreSQLTests.swift | 2 +- .../PerfectSessionSQLiteTests.swift | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index d5c0206..4995f32 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -113,12 +113,17 @@ public final class MySQLSessionDriver: SessionDriver, Sendable { private func connect() -> MySQL { let db = MySQL() if MySQLSessionConnector.socket.isEmpty { + // Not UInt32(_:), which traps on a negative port. Port 0 would mean the default + // (3306), so an out-of-range port leaves the connection unopened: every call fails. + guard let port = UInt32(exactly: MySQLSessionConnector.port), (1...65535).contains(port) else { + return db + } _ = db.connect( host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, - port: UInt32(MySQLSessionConnector.port) + port: port ) } else { _ = db.connect( diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 1be2416..55306ee 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -155,7 +155,7 @@ enum MySQLLive { /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", - "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) func sqlInTokensIsJustText(_ token: String) async throws { let driver = await getDriver() var other = await driver.create() @@ -216,6 +216,18 @@ enum MySQLLive { await driver.destroy(token: fresh.token) } + /// UInt32(port) trapped on a negative or too-large port. + @Test(arguments: [-1, Int(UInt32.max) + 1, 70_000]) + func invalidPortDoesNotTrap(_ port: Int) async { + let driver = await getDriver() + MySQLSessionConnector.port = port + defer { MySQLSessionConnector.port = MySQLLive.port ?? 0 } + let session = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async { _ = await getDriver() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 250fcd2..402b582 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -161,7 +161,7 @@ enum PostgreSQLLive { /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", - "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) func sqlInTokensIsJustText(_ token: String) async throws { let driver = await getDriver() var other = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index b62aa60..3fa57ba 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -123,7 +123,7 @@ enum SQLiteLive { /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", - "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\"]) + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) func sqlInTokensIsJustText(_ token: String) async throws { let driver = await getDriver() var other = await driver.create() From 7202fb95bf75c29cd68eeb0d88e87891cd9112bb Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:25:20 -0500 Subject: [PATCH 18/33] CI: Swift 6.2/6.4 x debug/release, warnings as errors, live tests required 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 --- .github/workflows/ci.yml | 97 +++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1189b5b..00ddff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,25 @@ on: push: pull_request: +permissions: + contents: read + +env: + MONGOC_VERSION: 2.5.5 + jobs: linux: # Every backend against a real server (SQLite uses a local file). - name: Linux (Swift 6.4, all backends) + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) runs-on: ubuntu-24.04 - container: swift:6.4-resolute + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] services: mongo: image: mongo:8 @@ -34,40 +47,100 @@ jobs: MONGODB_URI: mongodb://mongo:27017 PG_TESTS: "1" PG_HOST: postgres + PG_PORT: "5432" MYSQL_TESTS: "1" MYSQL_HOST: mysql + MYSQL_PORT: "3306" REDIS_TESTS: "1" REDIS_HOST: redis + REDIS_PORT: "6379" SQLITE_TESTS: "1" + # Fail, rather than skip, if a backend's tests aren't switched on or can't reach its server. + SESSION_REQUIRE_LIVE: "1" + # Expiry is epoch seconds; it must not depend on the process's time zone. + TZ: America/Denver steps: - uses: actions/checkout@v7 - name: Install client libraries run: | apt-get update - apt-get install -y --no-install-recommends pkg-config libmongoc-dev libbson-dev libpq-dev libmysqlclient-dev libsqlite3-dev + apt-get install -y --no-install-recommends pkg-config libpq-dev libmysqlclient-dev libsqlite3-dev \ + build-essential cmake curl libssl-dev libsasl2-dev libzstd-dev + + # Ubuntu 24.04's libmongoc is 1.x; Perfect-MongoDB 4 needs libmongoc 2. + - name: Cache libmongoc + id: mongoc-cache + uses: actions/cache@v6 + with: + path: /opt/mongoc + key: mongoc-${{ env.MONGOC_VERSION }}-noble + + - name: Build libmongoc + if: steps.mongoc-cache.outputs.cache-hit != 'true' + run: | + curl -fsSL https://github.com/mongodb/mongo-c-driver/releases/download/$MONGOC_VERSION/mongo-c-driver-$MONGOC_VERSION.tar.gz | tar xz + cmake -S mongo-c-driver-$MONGOC_VERSION -B mongoc-build \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/mongoc \ + -DENABLE_TESTS=OFF -DENABLE_EXAMPLES=OFF + cmake --build mongoc-build --parallel + cmake --install mongoc-build + + - name: Use libmongoc + run: | + echo "PKG_CONFIG_PATH=$(dirname "$(find /opt/mongoc -name mongoc2.pc)")" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$(dirname "$(find /opt/mongoc -name 'libmongoc2.so' | head -1)")" >> "$GITHUB_ENV" + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags - name: Test - run: swift test + run: swift test $FLAGS macos: - # GitHub's macOS runners have no database servers, so this covers the build plus the - # server-independent tests, including SQLite (a local file). - name: macOS (build, core and SQLite tests) + # GitHub's macOS runners have no database servers, so this covers the build, the + # server-independent tests and SQLite (a local file); the other live suites report as skipped. + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] env: SQLITE_TESTS: "1" + TZ: America/Denver steps: - uses: actions/checkout@v7 + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + - name: Install client libraries run: | - brew install mongo-c-driver mysql-client libpq - # Keg-only; older SwiftPM releases don't find libpq through its brew provider alone. + brew install mongo-c-driver mysql-client libpq pkgconf + # mysql-client and libpq are keg-only, so pkg-config can't find their .pc files without this. echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV" - - name: Toolchain - run: swift --version + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags - name: Test - run: swift test + run: swift test $FLAGS From d5364ed18ff5fdeb4477c52d7b8f4ed03d1f2a2f Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:27:38 -0500 Subject: [PATCH 19/33] README: 4.0.0 install, security notes, Perfect-NIO example, migration 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 --- README.md | 218 ++++++++++++++++++++++++++++++++++++++++-------- README.zh_CN.md | 94 ++++++++++++--------- 2 files changed, 240 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 64ae9e3..2be09da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

- Swift 6.2 + Swift 6.2+ Platforms macOS 12+ | Linux @@ -13,7 +13,8 @@

Session storage and session-security building blocks for Perfect, rewritten for Swift 6 and strict concurrency: -a session model, pluggable storage backends, and framework-independent CSRF, CORS and authentication checks. +a session model, pluggable storage backends, and framework-independent CSRF, CORS, cookie and authentication +helpers. This package **consolidates** what used to be separate `Perfect-Session-MySQL`/`-PostgreSQL`/`-Redis`/`-SQLite`/`-MongoDB` repos into one package with a product per backend; those repos are superseded by this one. @@ -22,16 +23,17 @@ repos into one package with a product per backend; those repos are superseded by ## Requirements - Swift 6.2 or later (`swift-tools-version: 6.2`). Every target builds in Swift 6 language mode. -- macOS 12 or later, or Linux. Every backend builds on Linux (tested with Swift 6.4 on Ubuntu 26.04). -- Each backend needs its database's client library; see the list below. +- macOS 12 or later, or Linux. CI builds and tests with Swift 6.2 and 6.4 on Ubuntu 24.04 (every backend against + a live server) and with Xcode 26.0.1 and 26.6 on macOS. +- Each backend needs its database's client library; see [Storage backends](#storage-backends). -## Building +## Installation -Add it to your `Package.swift`, with the backend you want: +Add the package and the backend you want: ``` swift dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"), ], targets: [ .target( @@ -45,47 +47,120 @@ targets: [ ] ``` -There is no tagged release of the Swift 6 version yet, so depend on `main`. The `3.x` tags are the pre-rewrite -version. Only the backends you depend on are built: an app using only the MySQL backend never compiles the -others or needs their libraries. +Only the backends you depend on are built: an app using only the MySQL backend never compiles the others or +needs their libraries. + +## What's in the core `PerfectSessionCore` has no external database dependency. It provides: -- `PerfectSession`, the session value (token, user ID, timestamps, `data`, CSRF token, and `isValid()` for - expiry and optional IP/user-agent locks); +- `PerfectSession`, the session value: token, user ID, timestamps, `data`, a CSRF token, `isExpired()`, and + `isValid()` (expiry plus optional IP-address and user-agent locks); - `SessionConfig`, for cookie, expiry, CSRF and CORS settings; - the `SessionDriver` protocol and `MemorySessionDriver`, an in-process store for development and tests; +- `SessionCookie`, which builds the `Set-Cookie` values for the session cookie from `SessionConfig`; - `AuthFilter` (path inclusion/exclusion rules), and `CSRFSecurity`/`CORSSecurity` (origin and host checks on header strings). -These don't depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver -(`create`, `resume`, `save`, `destroy`), and applies the CSRF, CORS and authentication checks. +None of these depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver, +and applies the checks. + +`SessionDriver` is an `async` protocol: `create`, `resume` (which also `throws`), `save`, `destroy`, +`regenerate`, `clean` and `setup`. Drivers are `Sendable`, so one driver instance can be shared across requests. + +### Session security + +- **Tokens.** Session and CSRF tokens are 32 bytes from the system's secure random number generator, as 64 hex + digits (`PerfectSession.newToken()`). +- **Expiry is enforced by the server.** Every driver's `resume` throws `InvalidSessionError` for a session that has + been idle for `idle` seconds (`SessionConfig.idle`, one day by default; each `save` renews it), or, if you set + `SessionConfig.absoluteTimeout`, that many seconds after it was created. Times are seconds since 1970, so the + process's time zone doesn't matter. `clean()` deletes expired sessions from storage. +- **Fixation.** `resume` only returns sessions the driver stored, and `save` only updates a stored session (so a + save racing a logout can't bring it back). Call `regenerate(_:)` when a user signs in or gains privileges, and + send the client the new token: it moves the session to a new token and CSRF token and destroys the old one. +- **Cookies.** `SessionCookie.setCookie(token:)` and `expiredCookie()` apply `SessionConfig`'s `name`, + `cookiePath`, `cookieDomain`, `cookieSecure`, `cookieHTTPOnly` (default on) and `cookieSameSite` (default + `Strict`). `SameSite=None` and the `__Secure-`/`__Host-` name prefixes always add `Secure`; set `cookieSecure` + yourself when you serve over HTTPS. They return nil instead of a malformed header if a setting or the token holds + a character a cookie can't. +- **Session data** is stored as JSON. Keep `data` to JSON values (`String`, `Bool`, finite numbers, `NSNull`, and + arrays and `[String: …]` dictionaries of them); if it holds anything else, such as a `Date`, the session is saved + with empty data. +- **Configuration** (`SessionConfig`, `AuthFilter.authenticationConfig` and each `…SessionConnector`) is global + state: set it once at startup, before serving requests. The checks also take explicit values + (`isValid(…, ipAddressLock:userAgentLock:)`, `CSRFSecurity.isValid(…, acceptableHostnames:)`, + `CORSSecurity.isAcceptable(…, config:)`). + +### Using it with Perfect-NIO + +A session filter in [Perfect-NIO](https://github.com/PerfectlySoft/Perfect-NIO) 0.1's route style: -`SessionDriver` is an `async` protocol: `create`, `resume` (which also `throws`), `save`, `destroy`, `clean` and -`setup`. Drivers are `Sendable`, so one driver instance can be shared across requests. +``` swift +import PerfectNIO +import PerfectSessionCore + +let driver: any SessionDriver = MemorySessionDriver() // or any backend's driver + +struct RequestSession: Sendable { + var session: PerfectSession + let isNew: Bool +} + +@Sendable func loadSession(_ req: any HTTPRequest) async -> RequestSession { + let ip = req.remoteAddress?.ipAddress ?? "" + let ua = req.headers["User-Agent"].first ?? "" + if let token = req.cookies[SessionConfig.name], + let session = try? await driver.resume(token: token), + session.isValid(ipaddress: ip, useragent: ua) { + return RequestSession(session: session, isNew: false) + } + return RequestSession(session: await driver.create(ipaddress: ip, useragent: ua), isNew: true) +} + +let routes: Routes = root(loadSession).map { state -> HTTPOutput in + var session = state.session + session.data["visits"] = (session.data["visits"] as? Int ?? 0) + 1 + await driver.save(session) + let headers = state.isNew ? SessionCookie.setCookie(token: session.token).map { [("Set-Cookie", $0)] } ?? [] : [] + return TextOutput("visits: \(session.data["visits"] ?? 0)", headers: headers) +} +``` ## Storage backends Each backend is a target and product in this package, with its own test suite. Configure a backend through its -`…SessionConnector` type. Its tests skip unless the switch shown is set to `1`, along with the connection -variables in that test file. - -| Product | Depends on | Needs | Tests | -|---|---|---|---| -| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL client library (`libmysqlclient`) | `MYSQL_TESTS` | -| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | -| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis), `swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS, Linux (`libsqlite3-dev` on Linux) | `SQLITE_TESTS` | -| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | +`…SessionConnector` type, once, at startup. + +| Product | Depends on | Needs | +|---|---|---| +| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL client library (`libmysqlclient`) | +| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` | +| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4, `swift-log` | — | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6, `swift-log` | `libsqlite3-dev` on Linux | +| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | + +On macOS, `mysql-client` and `libpq` from Homebrew are keg-only: set +`PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig`. + +**MySQL, PostgreSQL and SQLite** store a session per row in `table` (default `sessions`), created by `setup()`. +Every value is bound as a parameter. `created`, `updated` and `idle` are 64-bit (`bigint`/`int8`); see +[Migrating](#migrating-from-earlier-versions) for tables created before 4.0. `MySQLSessionConnector.port` must be +1–65535. PostgreSQL connects with `PostgreSQLSessionConnector.connectionString()`, which percent-encodes the user +name, password and database. + +The **Redis** backend stores each session as JSON under `RedisSessionConnector.keyPrefix` + token (default prefix +`perfect-session:`, so a client's token can't name your application's own keys), with a TTL of the session's +`idle`, renewed by each `save`. Each call opens its own connection. `RedisSessionConnector.commandTimeout` (seconds, +default nil: no limit) is passed to Perfect-Redis; set it so a stalled server can't hold requests indefinitely. The **MongoDB** backend stores each session as a document keyed by its token, with `data` as a subdocument. Its `setup()` adds a TTL index, so MongoDB deletes expired sessions itself. Besides `MongoDBSessionConnector` -(`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer. -`save` only updates a session that still exists, so a save that races a logout can't bring the session back; -a session that was never stored (its `create` failed) or has already expired isn't stored by `save` either. -libmongoc rejects an empty key, and a key is cut short at a NUL, so keys in `data` are escaped at every depth: -`%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys are stored as is, so queries such as -`data.role` keep working. +(`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer; +`MongoDBSessionDriver()` traps if `MongoDBSessionConnector.uri` isn't a valid connection string, while +`init(uri:database:collection:)` throws. libmongoc rejects an empty key, and a key is cut short at a NUL, so keys +in `data` are escaped at every depth: `%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys, +`$`-prefixed and dotted ones included, are stored as is, so queries such as `data.role` keep working. ### Migrating from Perfect-Session-MongoDB @@ -96,6 +171,81 @@ the new driver at the old collection, `setup()` deletes them: documents with a s `created`, `updated` and `idle` fields, and no `expiresAt`. Run `setup()` again once no old instances are left writing to the collection. To keep the old documents, use a different `collection`. -## Further information +## Running the tests + +``` sh +swift test # core tests; each backend's live suite reports as skipped +``` -The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. +Each backend's live suite runs when its switch is `1`. It drops and recreates its test table or collection, so +point it at a throwaway server: + +| Backend | Switch | Connection variables | +|---|---|---| +| MySQL | `MYSQL_TESTS` | `MYSQL_HOST`, `MYSQL_PORT` (required), `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | +| PostgreSQL | `PG_TESTS` | `PG_HOST`, `PG_PORT`, `PG_USER`, `PG_PASSWORD`, `PG_DATABASE` | +| Redis | `REDIS_TESTS` | `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` | +| MongoDB | `MONGODB_TESTS` | `MONGODB_URI` | +| SQLite | `SQLITE_TESTS` | — (a temporary file) | + +With `SESSION_REQUIRE_LIVE=1`, as in CI, a backend whose switch isn't set, or whose server can't be reached, fails +the run instead of skipping. + +## Known limitations + +- **NUL in user IDs and user agents.** PostgreSQL text can't hold a NUL character (the write fails, so the session + isn't stored or saved), and SQLite's text binding stops at it. Session `data` is unaffected (it's JSON). +- **`AuthFilter` matches strings exactly.** An inclusion of `/admin` doesn't cover `/admin/` or `/Admin`, and + `/admin/*` doesn't cover `/admin`. Normalize paths before checking them, or list each form. +- **Global configuration.** The settings are `nonisolated(unsafe)` statics: changing them while requests are being + served is a data race. +- **`PerfectSession.data` is `[String: Any]`.** `PerfectSession` is `@unchecked Sendable` on the understanding that + `data` holds only JSON values. + +## Migrating from earlier versions + +### From 3.x + +4.0 is a rewrite for Swift 6. The 3.x module `PerfectSession`, with its Perfect-HTTPServer request and response +filters (`SessionMemoryDriver`, `CSRFFilter`, `isValid(_ request:)`), and the separate per-backend repositories are +replaced by this package: + +- Depend on `PerfectSessionCore` plus a backend product, instead of `Perfect-Session` and a + `Perfect-Session-` package. +- There are no HTTP filters. Write a small middleware for your server (see + [Using it with Perfect-NIO](#using-it-with-perfect-nio)) that calls a `SessionDriver`, `SessionCookie`, + `CSRFSecurity`, `CORSSecurity` and `AuthFilter`. +- Drivers are `async`: `try await driver.resume(token:)`, `await driver.save(_:)` (was `save(session:)`), + `await driver.destroy(token:)`. `isValid` takes the client's IP address and user agent as strings. +- Logging uses swift-log instead of Perfect-Logger. +- Tokens are 64 hex digits instead of UUIDs, and stored 3.x sessions aren't migrated: users sign in again. + +### From the Swift 6 `main` branch + +If you used `main` before 4.0.0: + +- **Expired sessions are refused.** `resume` throws `InvalidSessionError` once a session's idle time has run out, + instead of returning it for you to check with `isValid()`. +- **Redis keys have a prefix.** Sessions are stored under `perfect-session:`, so sessions stored by `main` + aren't found (users sign in again). Set `RedisSessionConnector.keyPrefix = ""` to keep the old keys, but then a + client's token can name any key in that database. +- **Redis TTLs follow the session's `idle`** rather than `SessionConfig.idle`, and a session whose `idle` is 0 or + less is deleted rather than stored without a TTL. +- **`MemorySessionDriver.save` only updates** a session it has stored, like the other drivers. +- **Tokens are 64 hex digits** (they were UUIDs). +- **Wider columns.** Tables that `setup()` created before 4.0 keep 32-bit `created`/`updated`/`idle` columns, which + can't hold an `idle` past about 68 years or times after 2038. Widen them: + + ``` sql + -- MySQL + ALTER TABLE `sessions` MODIFY `created` bigint NOT NULL DEFAULT 0, MODIFY `updated` bigint NOT NULL DEFAULT 0, + MODIFY `idle` bigint NOT NULL DEFAULT 0; + -- PostgreSQL + ALTER TABLE "sessions" ALTER COLUMN "created" TYPE int8, ALTER COLUMN "updated" TYPE int8, + ALTER COLUMN "idle" TYPE int8; + ``` + +## Legacy + +The pre-Swift-6 code (3.1.5 plus the later OAuth2 flag) is preserved on the [`legacy`](../../tree/legacy) branch. +Stay on 3.x if you can't move to Swift 6.2 yet. diff --git a/README.zh_CN.md b/README.zh_CN.md index 348fa88..2e7129f 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -2,7 +2,7 @@

- Swift 6.2 + Swift 6.2+ Platforms macOS 12+ | Linux @@ -13,24 +13,25 @@

Perfect 的会话存储与会话安全基础组件,针对 Swift 6 与严格并发检查重写:包括会话模型、可插拔的存储后端, -以及不依赖具体 Web 框架的 CSRF、CORS 与身份验证检查。 +以及不依赖具体 Web 框架的 CSRF、CORS、Cookie 与身份验证辅助功能。 本包把过去分散的 `Perfect-Session-MySQL`/`-PostgreSQL`/`-Redis`/`-SQLite`/`-MongoDB` 仓库**合并**为一个包, 每个后端对应一个产品;上述仓库均已被本包取代。`Perfect-Session-CouchDB` 没有被沿用。 +完整说明(包括 Perfect-NIO 示例和迁移细节)以 [英文 README](README.md) 为准。 + ## 环境要求 - Swift 6.2 或更新版本(`swift-tools-version: 6.2`)。所有目标都在 Swift 6 语言模式下编译。 -- macOS 12 或更新版本,或 Linux。所有后端都可以在 Linux 上编译(已用 Ubuntu 26.04 上的 Swift 6.4 测试)。 +- macOS 12 或更新版本,或 Linux。CI 在 Ubuntu 24.04 上用 Swift 6.2 和 6.4 编译并测试(每个后端都连接真实服务器), + 并在 macOS 上用 Xcode 26.0.1 和 26.6 测试。 - 每个后端都需要对应数据库的客户端库,详见下方列表。 -## 构建 - -把它加入你的 `Package.swift`,并加上你需要的后端: +## 安装 ``` swift dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"), ], targets: [ .target( @@ -44,51 +45,68 @@ targets: [ ] ``` -Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分支。`3.x` 标签是重写之前的版本。只有你所依赖的后端 -才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。 +只有你所依赖的后端才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。 + +## 核心库 `PerfectSessionCore` 不依赖任何外部数据库。它提供: -- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌,以及用于检查过期和可选的 IP/用户代理 - 绑定的 `isValid()`); +- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌、`isExpired()`,以及检查过期和可选的 + IP/用户代理绑定的 `isValid()`); - `SessionConfig`:Cookie、过期、CSRF 与 CORS 设置; - `SessionDriver` 协议和 `MemorySessionDriver`,后者是用于开发和测试的进程内存储; +- `SessionCookie`:根据 `SessionConfig` 生成会话 Cookie 的 `Set-Cookie` 值; - `AuthFilter`(路径包含/排除规则),以及 `CSRFSecurity`/`CORSSecurity`(对请求头字符串进行来源和主机检查)。 -这些组件不依赖任何 HTTP 框架。由你的服务器中间件读取会话 Cookie、调用驱动(`create`、`resume`、`save`、 -`destroy`),并执行 CSRF、CORS 与身份验证检查。 +这些组件不依赖任何 HTTP 框架。`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、 +`destroy`、`regenerate`、`clean` 和 `setup`。驱动遵循 `Sendable`,同一个驱动实例可以在多个请求之间共享。 -`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、`destroy`、`clean` 和 -`setup`。驱动遵循 `Sendable`,因此同一个驱动实例可以在多个请求之间共享。 +### 会话安全 -## 存储后端 +- **令牌**:会话令牌和 CSRF 令牌取自系统的安全随机数生成器(32 字节,64 个十六进制字符)。 +- **过期由服务器强制执行**:会话空闲超过 `idle` 秒(每次 `save` 都会续期),或设置了 + `SessionConfig.absoluteTimeout` 且自创建起已超过该秒数时,所有驱动的 `resume` 都会抛出 `InvalidSessionError`。 + 时间以 1970 年以来的秒数存储,与进程时区无关。 +- **会话固定**:`resume` 只返回驱动已存储的会话,`save` 只更新已存储的会话。用户登录或权限变化时调用 + `regenerate(_:)`,并把新令牌发给客户端。 +- **Cookie**:`SessionCookie.setCookie(token:)` 与 `expiredCookie()` 使用 `SessionConfig` 的 Cookie 设置 + (默认 `HttpOnly`、`SameSite=Strict`);`SameSite=None` 和 `__Secure-`/`__Host-` 前缀总会加上 `Secure`。 +- **会话数据**以 JSON 存储,`data` 只能存放 JSON 值;否则会话以空数据保存。 +- **配置**是全局状态,请在启动时设置一次,然后再开始处理请求。 -每个后端都是本包中的一个目标和产品,并有各自的测试套件。通过对应的 `…SessionConnector` 类型配置后端。测试默认 -会跳过,除非把表中所示的开关设为 `1`,并设置该测试文件中的连接变量。 +## 存储后端 -| 产品 | 依赖 | 需要 | 测试 | -|---|---|---|---| -| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL 客户端库(`libmysqlclient`) | `MYSQL_TESTS` | -| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | -| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis)、`swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS、Linux(Linux 上需要 `libsqlite3-dev`) | `SQLITE_TESTS` | -| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | +| 产品 | 依赖 | 需要 | +|---|---|---| +| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL 客户端库(`libmysqlclient`) | +| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` | +| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4、`swift-log` | — | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6、`swift-log` | Linux 上需要 `libsqlite3-dev` | +| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | -**MongoDB** 后端把每个会话存储为一个以其令牌为键的文档,`data` 以子文档形式存储。它的 `setup()` 会添加 TTL -索引,让 MongoDB 自动删除过期会话。除了 `MongoDBSessionConnector`(`uri`、`database`、`collection`)之外, -你也可以向其初始化方法传入 URI 或现有的 `MongoClientPool`。 -`save` 只会更新仍然存在的会话,因此与注销同时发生的保存不会让会话复活;从未存储成功(其 `create` 失败)或已经过期的 -会话,`save` 也不会再存储。libmongoc 拒绝空键,而键会在 NUL 处被截断,因此 `data` 中各层的键都会被转义:`%` -存为 `%25`,NUL 存为 `%00`,空键存为 `%`。其他键原样存储,因此 `data.role` 之类的查询照常可用。 +**MySQL、PostgreSQL 与 SQLite** 后端每个会话一行,所有值都以参数绑定;`created`、`updated`、`idle` 为 64 位列。 +**Redis** 后端把会话以 JSON 存在 `RedisSessionConnector.keyPrefix`(默认 `perfect-session:`)加令牌的键下, +TTL 为会话的 `idle`;`RedisSessionConnector.commandTimeout` 会传给 Perfect-Redis。 +**MongoDB** 后端每个会话一个文档,`data` 为子文档,`setup()` 会添加 TTL 索引;`data` 中的空键、`%` 和 NUL 会被转义。 ### 从 Perfect-Session-MongoDB 迁移 -已归档的 `Perfect-Session-MongoDB` 把每个会话存储在它自己的 `_id` 下,令牌放在 `token` 字段中,`data` 是 JSON -文本,并且没有过期时间。MongoDB 后端无法读取这些文档,因此切换后现有会话都会结束,用户需要重新登录。TTL 索引也 -永远不会删除它们,所以如果你让新驱动使用旧的集合,`setup()` 会删除这些文档:即 `token` 为字符串、`data` 为字符串、 -含有 `created`、`updated` 和 `idle` 字段且没有 `expiresAt` 的文档。等不再有旧实例写入该集合后,再运行一次 -`setup()`。如果想保留旧文档,请使用另一个 `collection`。 +已归档的 `Perfect-Session-MongoDB` 的文档无法被新后端读取,切换后现有会话都会结束。如果新驱动使用旧集合, +`setup()` 会删除这些旧文档(`token` 与 `data` 为字符串、含 `created`/`updated`/`idle` 且没有 `expiresAt`)。 +如想保留旧文档,请使用另一个 `collection`。 + +## 运行测试 + +各后端的实时测试在对应开关为 `1` 时运行:`MYSQL_TESTS`(还需 `MYSQL_PORT`)、`PG_TESTS`、`REDIS_TESTS`、 +`MONGODB_TESTS`、`SQLITE_TESTS`;连接变量见英文 README。设置 `SESSION_REQUIRE_LIVE=1`(CI 中如此)时, +未开启或无法连接服务器的后端会导致测试失败,而不是跳过。 + +## 迁移 + +从 3.x 迁移:模块与 HTTP 过滤器已被替换,驱动改为 `async`,需要自己编写中间件。从 4.0.0 之前的 `main` 迁移: +过期会话会被拒绝、Redis 键加了前缀、令牌改为 64 个十六进制字符、旧表需要把时间列改为 64 位。详见英文 README 的 +“Migrating from earlier versions”一节。 -## 更多信息 +## 旧版本 -本包在 Swift 6 之前的版本保留在 [`legacy`](../../tree/legacy) 分支上。 +本包在 Swift 6 之前的代码(3.1.5 加上之后的 OAuth2 标志)保留在 [`legacy`](../../tree/legacy) 分支上。 From 44f2fdd0a984545a597fe1f3b3fbbc24562a82c0 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:13:12 -0500 Subject: [PATCH 20/33] AuthFilter: segment matching, case-folding, fail closed; requiresAuthentication 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 --- Sources/PerfectSessionCore/AuthFilter.swift | 83 ++++++++++++++----- .../PerfectSessionCoreTests.swift | 78 +++++++++++++++-- 2 files changed, 134 insertions(+), 27 deletions(-) diff --git a/Sources/PerfectSessionCore/AuthFilter.swift b/Sources/PerfectSessionCore/AuthFilter.swift index 1adcc2c..051960d 100644 --- a/Sources/PerfectSessionCore/AuthFilter.swift +++ b/Sources/PerfectSessionCore/AuthFilter.swift @@ -1,10 +1,17 @@ import Foundation -/// Configures which URL paths require authentication. +/// Configures which URL paths require authentication. See `AuthFilter.requiresAuthentication`. public struct AuthenticationConfig: Sendable { + /// Paths that require authentication. `"/admin"` covers `/admin` and everything below it + /// (not `/administrator`); `"/admin/*"` means the same; `"*"` covers every path. public var inclusions = [String]() + /// Paths that don't, overriding `inclusions`, with the same matching. public var exclusions = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Redirect or respond in your middleware.") public var denied: String? + /// Compare paths ignoring ASCII case (the default), for servers whose routing ignores + /// case, as Perfect-NIO 0.1's does. + public var caseInsensitive = true public init() {} @@ -14,30 +21,68 @@ public struct AuthenticationConfig: Sendable { public mutating func exclude(_ arr: [String]) { exclusions += arr } } -/// Pure-string path matching for authentication gating (no HTTP types needed). +/// Path matching for authentication gating (no HTTP types needed). public struct AuthFilter: Sendable { nonisolated(unsafe) public static var authenticationConfig = AuthenticationConfig() - /// Checks the global authenticationConfig; pass `config` to use a local config instead. - public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { + /// Whether **path** requires authentication under **config** (by default + /// `authenticationConfig`). Pass the request's path without its query string. + /// + /// The path is split on `/`, empty and `.` segments are dropped, and segments are compared + /// ignoring case unless `caseInsensitive` is off. A pattern matches its own path and every + /// path below it. `*` is a wildcard only as a pattern's whole final segment; an inclusion + /// that uses `*` any other way (`"*.json"`, `"/admin*"`) makes every path require + /// authentication, and such an exclusion matches nothing. + /// + /// When any inclusion is configured, a path containing a `..` segment, `%`, `;` or `\` + /// requires authentication whatever the patterns say: different servers decode and + /// normalize those differently, so the path can't be matched reliably. + public static func requiresAuthentication(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { let cfg = config ?? authenticationConfig - var checkAuth = false - let wildcardInclusions = cfg.inclusions.filter { $0.contains("*") } - let wildcardExclusions = cfg.exclusions.filter { $0.contains("*") } - - if cfg.inclusions.contains(path) { checkAuth = true } - for wInc in wildcardInclusions { - if wInc == "*" { checkAuth = true } - else if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) { - checkAuth = true + guard !cfg.inclusions.isEmpty else { return false } + if path.contains(where: { $0 == "%" || $0 == ";" || $0 == "\\" }) { return true } + guard let segments = normalize(path, caseInsensitive: cfg.caseInsensitive) else { return true } + var required = false + for pattern in cfg.inclusions { + switch match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) { + case .matches, .invalid: required = true + case .noMatch: break } + if required { break } } - if cfg.exclusions.contains(path) { checkAuth = false } - for wInc in wildcardExclusions { - if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) { - checkAuth = false - } + guard required else { return false } + for pattern in cfg.exclusions + where match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) == .matches { + return false } - return checkAuth + return true + } + + /// True when **path** requires authentication: the same as `requiresAuthentication`. + @available(*, deprecated, renamed: "requiresAuthentication(_:using:)", + message: "Returns true when authentication is REQUIRED; use requiresAuthentication(_:using:).") + public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { + requiresAuthentication(path, using: config) + } + + enum Match { case matches, noMatch, invalid } + + /// Path segments without empty and `.` ones, or nil if there's a `..` segment. + static func normalize(_ path: String, caseInsensitive: Bool) -> [Substring]? { + let text = caseInsensitive ? path.lowercased() : path + var segments: [Substring] = [] + for segment in text.split(separator: "/", omittingEmptySubsequences: true) where segment != "." { + if segment == ".." { return nil } + segments.append(segment) + } + return segments + } + + static func match(_ segments: [Substring], pattern: String, caseInsensitive: Bool) -> Match { + guard var parts = normalize(pattern, caseInsensitive: caseInsensitive) else { return .invalid } + if parts.last == "*" { parts.removeLast() } + guard !parts.contains(where: { $0.contains("*") }) else { return .invalid } + guard segments.count >= parts.count else { return .noMatch } + return zip(segments, parts).allSatisfy { $0 == $1 } ? .matches : .noMatch } } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 3f573cc..b3bd086 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -337,29 +337,91 @@ let underThreadSanitizer: Bool = { // MARK: - AuthFilter (uses local config to avoid parallel-test races on global state) + func requiresAuth(_ path: String, _ config: AuthenticationConfig) -> Bool { + AuthFilter.requiresAuthentication(path, using: config) + } + @Test func authFilterInclusion() { var config = AuthenticationConfig() config.include("/admin") config.include("/profile") - #expect(AuthFilter.shouldWeAccept("/admin", using: config)) - #expect(AuthFilter.shouldWeAccept("/profile", using: config)) - #expect(!AuthFilter.shouldWeAccept("/public", using: config)) + #expect(requiresAuth("/admin", config)) + #expect(requiresAuth("/profile", config)) + #expect(!requiresAuth("/public", config)) } @Test func authFilterWildcard() { var config = AuthenticationConfig() config.include("/admin/*") - #expect(AuthFilter.shouldWeAccept("/admin/users", using: config)) - #expect(AuthFilter.shouldWeAccept("/admin/settings", using: config)) - #expect(!AuthFilter.shouldWeAccept("/public/page", using: config)) + #expect(requiresAuth("/admin/users", config)) + #expect(requiresAuth("/admin/settings", config)) + #expect(requiresAuth("/admin", config)) + #expect(!requiresAuth("/public/page", config)) } @Test func authFilterExclusion() { var config = AuthenticationConfig() config.include("*") config.exclude("/healthcheck") - #expect(AuthFilter.shouldWeAccept("/anything", using: config)) - #expect(!AuthFilter.shouldWeAccept("/healthcheck", using: config)) + #expect(requiresAuth("/anything", config)) + #expect(!requiresAuth("/healthcheck", config)) + } + + /// Perfect-NIO 0.1.0 routes /ADMIN and /Admin/users to the /admin handlers, and the old + /// string matching said they needed no authentication. + @Test(arguments: ["/admin", "/Admin", "/ADMIN", "/admin/", "//admin", "/./admin", "/Admin/users", + "/ADMIN/USERS", "/admin/users/", "/admin//users", "/admin/./users", + "/%61dmin", "/admin;x=1", "/x/../admin", "/admin\\users", "admin"]) + func authFilterNormalizesPaths(_ path: String) { + var config = AuthenticationConfig() + config.include("/admin") + #expect(requiresAuth(path, config), "\(path)") + } + + /// A pattern covers its own path and everything below it, segment by segment. + @Test func authFilterMatchesWholeSegments() { + var config = AuthenticationConfig() + config.include("/admin") + #expect(!requiresAuth("/administrator", config)) + #expect(!requiresAuth("/adm", config)) + #expect(!requiresAuth("/", config)) + config = AuthenticationConfig() + config.include("/api/*") + config.exclude("/api/public") + #expect(requiresAuth("/api/x", config)) + #expect(!requiresAuth("/api/public", config)) + #expect(!requiresAuth("/API/Public/docs", config)) + #expect(!requiresAuth("/apis", config)) + } + + /// `*` is only a wildcard as a whole final segment. An inclusion that uses it any other + /// way ("*.json", "/admin*") requires authentication everywhere rather than silently + /// protecting nothing; such an exclusion matches nothing. + @Test func authFilterRejectsPartialWildcards() { + var config = AuthenticationConfig() + config.include("/admin*") + #expect(requiresAuth("/administrator", config)) + #expect(requiresAuth("/public", config)) + config = AuthenticationConfig() + config.include("*") + config.exclude("*.json") + #expect(requiresAuth("/x.json", config)) + } + + /// Case-folding can be turned off for servers that route case-sensitively. + @Test func authFilterCaseSensitiveOption() { + var config = AuthenticationConfig() + config.include("/admin") + config.caseInsensitive = false + #expect(requiresAuth("/admin/x", config)) + #expect(!requiresAuth("/Admin", config)) + } + + /// With nothing protected, odd characters don't start requiring authentication. + @Test func authFilterNoInclusionsNeverRequiresAuth() { + let config = AuthenticationConfig() + #expect(!requiresAuth("/caf%C3%A9", config)) + #expect(!requiresAuth("/../x", config)) } // MARK: - CSRFSecurity From 76402391b8cd250a04b38a0f363f05fa4027576c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:15:08 -0600 Subject: [PATCH 21/33] A value JSON can't hold drops only its own key, never the CSRF token 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 --- Package.swift | 3 ++ .../MemorySessionDriver.swift | 1 + .../PerfectSessionCore/PerfectSession.swift | 41 ++++++++++++---- .../MongoDBSessionDriver.swift | 1 + .../MySQLSessionDriver.swift | 1 + .../PostgreSQLSessionDriver.swift | 1 + .../RedisSessionDriver.swift | 5 +- .../SQLiteSessionDriver.swift | 1 + .../PerfectSessionCoreTests.swift | 47 ++++++++++++++++++- .../PerfectSessionMongoDBTests.swift | 28 +++++++++++ .../PerfectSessionMySQLTests.swift | 28 +++++++++++ .../PerfectSessionPostgreSQLTests.swift | 28 +++++++++++ .../PerfectSessionRedisTests.swift | 40 +++++++++++----- .../PerfectSessionSQLiteTests.swift | 28 +++++++++++ 14 files changed, 228 insertions(+), 25 deletions(-) diff --git a/Package.swift b/Package.swift index b4832f9..3456285 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,9 @@ let package = Package( targets: [ .target( name: "PerfectSessionCore", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ], swiftSettings: [.swiftLanguageMode(.v6)] ), .target( diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index 161bcdf..bb56485 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -24,6 +24,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { let found = lock.withLock { sessions[token] } guard var session = found, !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one return session } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index a2377f3..accbfca 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -1,4 +1,7 @@ import Foundation +import Logging + +private let logger = Logger(label: "perfect.session") /// A session: its token, user, timestamps and data. /// @@ -12,9 +15,9 @@ public struct PerfectSession: @unchecked Sendable { public var updated = 0 public var idle = SessionConfig.idle /// The session's values. Store only JSON values: `String`, `Bool`, finite numbers, `NSNull`, - /// and arrays and `[String: …]` dictionaries of them. If anything else is in here (a `Date`, - /// a `URL`, NaN, a dictionary with non-string keys), `tojson()` returns `"{}"`, so the - /// drivers save the session with empty data. + /// and arrays and `[String: …]` dictionaries of them. A top-level key whose value holds + /// anything else (a `Date`, a `URL`, NaN, a dictionary with non-string keys) isn't saved; its + /// name is logged. See `jsonSafeData()`. public var data = [String: Any]() public var ipaddress = "" public var useragent = "" @@ -27,12 +30,34 @@ public struct PerfectSession: @unchecked Sendable { updated = now } - /// `data` as JSON text, or `"{}"` if it holds a value JSON can't represent. - public func tojson() -> String { + /// `data` without the top-level keys whose values JSON can't hold, which are logged by name + /// (never by value). The CSRF token is never dropped: if it isn't a JSON value, a new one + /// replaces it. + public func jsonSafeData() -> [String: Any] { // JSONSerialization raises an Objective-C exception for such values, which `try?` can't - // catch and which terminates the process, so check first. - guard JSONSerialization.isValidJSONObject(data), - let d = try? JSONSerialization.data(withJSONObject: data), + // catch and which terminates the process, so check each one first. + guard !JSONSerialization.isValidJSONObject(data) else { return data } + var safe = [String: Any]() + var dropped = [String]() + for (key, value) in data { + if JSONSerialization.isValidJSONObject([key: value]) { + safe[key] = value + } else if key == "csrf" { + safe[key] = PerfectSession.newToken() + } else { + dropped.append(key) + } + } + if !dropped.isEmpty { + logger.warning("session data not saved: values JSON can't hold", + metadata: ["keys": .array(dropped.sorted().map { .string($0) })]) + } + return safe + } + + /// `jsonSafeData()` as JSON text. + public func tojson() -> String { + guard let d = try? JSONSerialization.data(withJSONObject: jsonSafeData()), let s = String(data: d, encoding: .utf8) else { return "{}" } return s } diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index dca1d6c..c47cae7 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -104,6 +104,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { throw InvalidSessionError() } session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one return session } diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 4995f32..3eb0bb1 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -72,6 +72,7 @@ public final class MySQLSessionDriver: SessionDriver, Sendable { } guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index 7173425..ce22ceb 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -88,6 +88,7 @@ public final class PostgreSQLSessionDriver: SessionDriver, Sendable { s.useragent = result.getFieldString(tupleIndex: 0, fieldIndex: 7) ?? "" guard !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 452fdb4..b9e3f7f 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -60,6 +60,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { throw InvalidSessionError() } session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one return session } @@ -134,10 +135,8 @@ public final class RedisSessionDriver: SessionDriver, Sendable { "idle": session.idle, "ipaddress": session.ipaddress, "useragent": session.useragent, - "data": JSONSerialization.isValidJSONObject(session.data) ? session.data : [:], + "data": session.jsonSafeData(), ] - // JSONSerialization raises an uncatchable exception for a value JSON can't hold. Like - // tojson(), save such data as empty rather than terminate the process. guard JSONSerialization.isValidJSONObject(dict), let d = try? JSONSerialization.data(withJSONObject: dict), let s = String(data: d, encoding: .utf8) else { return nil } diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 76cd022..88827b7 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -87,6 +87,7 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { ) guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index b3bd086..cc2ba83 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -52,7 +52,8 @@ let underThreadSanitizer: Bool = { @Test(arguments: ["nan", "infinity", "date", "url", "intKey", "nestedDate"]) func sessionToJsonNonJSONValueDoesNotCrash(_ kind: String) { var s = PerfectSession() - s.data["ok"] = "kept out" + s.data["ok"] = "kept" + s.data["csrf"] = "csrf-token" switch kind { case "nan": s.data["x"] = Double.nan case "infinity": s.data["x"] = Double.infinity @@ -61,7 +62,21 @@ let underThreadSanitizer: Bool = { case "intKey": s.data["x"] = [1: "one"] default: s.data["x"] = ["when": [Date()]] } - #expect(s.tojson() == "{}") + // Only the bad key goes; the rest of the data and the CSRF token stay. + var restored = PerfectSession() + restored.fromjson(s.tojson()) + #expect(restored.data["ok"] as? String == "kept") + #expect(restored.data["csrf"] as? String == "csrf-token") + #expect(restored.data["x"] == nil) + } + + /// A CSRF token JSON can't hold is replaced, never dropped. + @Test func sessionToJsonReplacesBadCSRF() { + var s = PerfectSession() + s.data["csrf"] = Date() + var restored = PerfectSession() + restored.fromjson(s.tojson()) + #expect((restored.data["csrf"] as? String)?.count == 64) } @Test func sessionFromJsonBadInput() { @@ -322,6 +337,34 @@ let underThreadSanitizer: Bool = { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + // The memory driver keeps values as they are, so "when" survives here. + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 064ad1f..89e2d45 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -290,6 +290,34 @@ enum MongoDBLive { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = try getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = try getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = try getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 55306ee..9935673 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -197,6 +197,34 @@ enum MySQLLive { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 402b582..52fa912 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -203,6 +203,34 @@ enum PostgreSQLLive { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 779aea4..e4ed7ee 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -49,18 +49,6 @@ enum RedisLive { await driver.destroy(token: session.token) } - @Test func saveWithNonJSONValueDoesNotCrash() async throws { - let driver = getDriver() - var session = await driver.create() - session.userid = "redis-user-2" - session.data["when"] = Date() - await driver.save(session) - let resumed = try await driver.resume(token: session.token) - #expect(resumed.userid == "redis-user-2") - #expect(resumed.data.isEmpty) - await driver.destroy(token: session.token) - } - @Test func destroy() async { let driver = getDriver() let session = await driver.create() @@ -178,6 +166,34 @@ enum RedisLive { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 3fa57ba..2843c62 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -165,6 +165,34 @@ enum SQLiteLive { await driver.destroy(token: session.token) } + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From 1847a2c3e04f29f5e082652b00b49a23044402ef Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:22:36 -0600 Subject: [PATCH 22/33] Every driver's save() renews the idle time 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 --- .../MemorySessionDriver.swift | 4 ++- .../MongoDBSessionDriver.swift | 4 +++ .../MySQLSessionDriver.swift | 4 +++ .../PostgreSQLSessionDriver.swift | 4 +++ .../SQLiteSessionDriver.swift | 4 +++ .../PerfectSessionCoreTests.swift | 23 +++++++++++++--- .../PerfectSessionMongoDBTests.swift | 23 +++++++++++++--- .../PerfectSessionMySQLTests.swift | 26 ++++++++++++++----- .../PerfectSessionPostgreSQLTests.swift | 26 ++++++++++++++----- .../PerfectSessionRedisTests.swift | 17 ++++++++++++ .../PerfectSessionSQLiteTests.swift | 23 +++++++++++++--- 11 files changed, 133 insertions(+), 25 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index bb56485..ab0a6a0 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -28,9 +28,11 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { return session } - /// Updates a stored session. A session that isn't stored (destroyed, cleaned, or never + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't stored (destroyed, cleaned, or never /// created by this driver) isn't stored again, so a save racing a logout can't bring it back. public func save(_ session: PerfectSession) async { + var session = session + session.touch() lock.withLock { if sessions[session.token] != nil { sessions[session.token] = session } } diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index c47cae7..d16aa5d 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -108,7 +108,11 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { return session } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() let document = SessionDocument(session) do { let matched = try await withCollection { sessions in diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 3eb0bb1..43e219b 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -76,7 +76,11 @@ public final class MySQLSessionDriver: SessionDriver, Sendable { return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() exec( "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,updated=?,idle=?,data=? WHERE token=?", params: [session.userid, session.updated, session.idle, session.tojson(), session.token] diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index ce22ceb..59ac0a8 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -92,7 +92,11 @@ public final class PostgreSQLSessionDriver: SessionDriver, Sendable { return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() exec( "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,updated=$2,idle=$3,data=$4 WHERE token=$5", params: [session.userid, session.updated, session.idle, session.tojson(), session.token] diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 88827b7..9df0deb 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -91,7 +91,11 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() do { let db = try SQLite(SQLiteSessionConnector.databasePath) defer { db.close() } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index cc2ba83..0b20e06 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -155,8 +155,7 @@ let underThreadSanitizer: Bool = { @Test func memoryResumeRejectsExpired() async { let driver = MemorySessionDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) @@ -365,12 +364,28 @@ let underThreadSanitizer: Bool = { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() // expire: set updated far in the past so clean() will purge it - session.idle = 1 - session.updated = 0 // epoch — definitely expired + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await driver.clean() await #expect(throws: InvalidSessionError.self) { diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 89e2d45..7ef42ee 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -199,8 +199,7 @@ enum MongoDBLive { @Test func resumeRejectsExpired() async throws { let driver = try getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) @@ -246,8 +245,7 @@ enum MongoDBLive { #expect(hasTTL) var expired = await driver.create() - expired.updated = Int(Date().timeIntervalSince1970) - 10 - expired.idle = 1 + expired.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(expired) let live = await driver.create() // Counted directly: resume() refuses an expired session whether or not clean() deleted it. @@ -318,6 +316,23 @@ enum MongoDBLive { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = try getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = try getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 9935673..89e8443 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -118,8 +118,7 @@ enum MySQLLive { session.idle = Int.max await driver.save(session) var expired = await driver.create() - expired.idle = 1 - expired.updated -= 10 + expired.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(expired) await driver.clean() #expect(try await driver.resume(token: session.token).idle == Int.max) @@ -132,8 +131,7 @@ enum MySQLLive { @Test func resumeRejectsExpired() async throws { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) @@ -144,8 +142,7 @@ enum MySQLLive { @Test func clean() async { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) #expect(isStored(session.token)) await driver.clean() @@ -225,6 +222,23 @@ enum MySQLLive { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 52fa912..5e32f93 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -112,8 +112,7 @@ enum PostgreSQLLive { session.idle = Int.max await driver.save(session) var expired = await driver.create() - expired.idle = 1 - expired.updated -= 10 + expired.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(expired) await driver.clean() #expect(try await driver.resume(token: session.token).idle == Int.max) @@ -126,8 +125,7 @@ enum PostgreSQLLive { @Test func resumeRejectsExpired() async throws { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) @@ -138,8 +136,7 @@ enum PostgreSQLLive { @Test func clean() async { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) #expect(isStored(session.token)) await driver.clean() @@ -231,6 +228,23 @@ enum PostgreSQLLive { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index e4ed7ee..94faa5f 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -194,6 +194,23 @@ enum RedisLive { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 2843c62..9676c92 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -100,8 +100,7 @@ enum SQLiteLive { @Test func resumeRejectsExpired() async throws { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) @@ -112,8 +111,7 @@ enum SQLiteLive { @Test func clean() async throws { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) #expect(try isStored(session.token)) await driver.clean() @@ -193,6 +191,23 @@ enum SQLiteLive { await driver.destroy(token: session.token) } + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From 3c299302c81a87e50e9deac43874e39879413993 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:23:42 -0600 Subject: [PATCH 23/33] regenerate() keeps the session's creation time 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 --- Sources/PerfectSessionCore/SessionDriver.swift | 3 ++- Sources/PerfectSessionMySQL/MySQLSessionDriver.swift | 4 ++-- .../PostgreSQLSessionDriver.swift | 4 ++-- .../PerfectSessionSQLite/SQLiteSessionDriver.swift | 5 +++-- .../PerfectSessionCoreTests.swift | 12 ++++++++++++ .../PerfectSessionMongoDBTests.swift | 12 ++++++++++++ .../PerfectSessionMySQLTests.swift | 12 ++++++++++++ .../PerfectSessionPostgreSQLTests.swift | 12 ++++++++++++ .../PerfectSessionRedisTests.swift | 12 ++++++++++++ .../PerfectSessionSQLiteTests.swift | 12 ++++++++++++ 10 files changed, 81 insertions(+), 7 deletions(-) diff --git a/Sources/PerfectSessionCore/SessionDriver.swift b/Sources/PerfectSessionCore/SessionDriver.swift index 808e1aa..8f6f0dc 100644 --- a/Sources/PerfectSessionCore/SessionDriver.swift +++ b/Sources/PerfectSessionCore/SessionDriver.swift @@ -22,7 +22,7 @@ extension SessionDriver { public func clean() async {} public func setup() async {} - /// Moves **session**'s user, data and idle time to a newly created session with a new token + /// Moves **session**'s user, data, creation time and idle time to a newly created session with a new token /// and a new CSRF token, then destroys the old session. Call it whenever the user signs in, /// signs out to a guest session, or gains privileges, and send the client the new token: /// a token an attacker planted or saw beforehand (session fixation) then no longer works. @@ -33,6 +33,7 @@ extension SessionDriver { var fresh = await create(ipaddress: session.ipaddress, useragent: session.useragent) let csrf = fresh.data["csrf"] fresh.userid = session.userid + fresh.created = session.created // keeps the absolute timeout running fresh.idle = session.idle fresh.data = session.data fresh.data["csrf"] = csrf diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 43e219b..555a78a 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -82,8 +82,8 @@ public final class MySQLSessionDriver: SessionDriver, Sendable { var session = session session.touch() exec( - "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,updated=?,idle=?,data=? WHERE token=?", - params: [session.userid, session.updated, session.idle, session.tojson(), session.token] + "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,created=?,updated=?,idle=?,data=? WHERE token=?", + params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token] ) } diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index 59ac0a8..f1976ea 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -98,8 +98,8 @@ public final class PostgreSQLSessionDriver: SessionDriver, Sendable { var session = session session.touch() exec( - "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,updated=$2,idle=$3,data=$4 WHERE token=$5", - params: [session.userid, session.updated, session.idle, session.tojson(), session.token] + "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,created=$2,updated=$3,idle=$4,data=$5 WHERE token=$6", + params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token] ) } diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 9df0deb..b0fcdd1 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -100,7 +100,7 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { let db = try SQLite(SQLiteSessionConnector.databasePath) defer { db.close() } try db.execute( - statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=? WHERE token=?", + statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=?,created=? WHERE token=?", doBindings: { stmt in try stmt.bind(position: 1, session.userid) try stmt.bind(position: 2, session.updated) @@ -108,7 +108,8 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { try stmt.bind(position: 4, session.tojson()) try stmt.bind(position: 5, session.ipaddress) try stmt.bind(position: 6, session.useragent) - try stmt.bind(position: 7, session.token) + try stmt.bind(position: 7, session.created) + try stmt.bind(position: 8, session.token) } ) } catch { diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 0b20e06..8a682f6 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -381,6 +381,18 @@ let underThreadSanitizer: Bool = { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 7ef42ee..a8784d4 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -333,6 +333,18 @@ enum MongoDBLive { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = try getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = try getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 89e8443..2751f30 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -239,6 +239,18 @@ enum MySQLLive { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index 5e32f93..d60fdba 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -245,6 +245,18 @@ enum PostgreSQLLive { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 94faa5f..0d057d2 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -211,6 +211,18 @@ enum RedisLive { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 9676c92..b64c202 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -208,6 +208,18 @@ enum SQLiteLive { await driver.destroy(token: session.token) } + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From dc3112d2fab7859528dac8bb59ac35cd86bde94c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:25:01 -0600 Subject: [PATCH 24/33] Log a SHA-256 prefix of the session token, never the token 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 --- Sources/PerfectSessionCore/LogID.swift | 62 +++++++++++++++++++ .../MongoDBSessionDriver.swift | 10 +-- .../RedisSessionDriver.swift | 10 +-- .../SQLiteSessionDriver.swift | 6 +- .../PerfectSessionCoreTests.swift | 9 +++ .../PerfectSessionMongoDBTests.swift | 42 +++++++++++++ .../PerfectSessionRedisTests.swift | 43 +++++++++++++ .../PerfectSessionSQLiteTests.swift | 42 +++++++++++++ 8 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 Sources/PerfectSessionCore/LogID.swift diff --git a/Sources/PerfectSessionCore/LogID.swift b/Sources/PerfectSessionCore/LogID.swift new file mode 100644 index 0000000..fa90b41 --- /dev/null +++ b/Sources/PerfectSessionCore/LogID.swift @@ -0,0 +1,62 @@ +extension PerfectSession { + /// An identifier for **token** that is safe to log: the first 12 hex digits of its SHA-256. + /// It tells log lines about the same session apart without letting a log reader take over the + /// session. The drivers log this, never the token. + public static func logID(_ token: String) -> String { + String(sha256Hex(token).prefix(12)) + } + + /// SHA-256 of **text**'s UTF-8 bytes, as 64 lowercase hex digits. (FIPS 180-4; small and + /// self-contained so the core needs no crypto dependency.) + static func sha256Hex(_ text: String) -> String { + var h: [UInt32] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19] + var message = Array(text.utf8) + let bitLength = UInt64(message.count) &* 8 + message.append(0x80) + while message.count % 64 != 56 { message.append(0) } + for shift in stride(from: 56, through: 0, by: -8) { message.append(UInt8(truncatingIfNeeded: bitLength >> UInt64(shift))) } + var w = [UInt32](repeating: 0, count: 64) + for chunk in stride(from: 0, to: message.count, by: 64) { + for i in 0..<16 { + let b = chunk + i * 4 + w[i] = UInt32(message[b]) << 24 | UInt32(message[b + 1]) << 16 | UInt32(message[b + 2]) << 8 | UInt32(message[b + 3]) + } + for i in 16..<64 { + let s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3) + let s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10) + w[i] = w[i - 16] &+ s0 &+ w[i - 7] &+ s1 + } + var (a, b, c, d, e, f, g, hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]) + for i in 0..<64 { + let s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) + let ch = (e & f) ^ (~e & g) + let t1 = hh &+ s1 &+ ch &+ k[i] &+ w[i] + let s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) + let maj = (a & b) ^ (a & c) ^ (b & c) + let t2 = s0 &+ maj + (hh, g, f, e, d, c, b, a) = (g, f, e, d &+ t1, c, b, a, t1 &+ t2) + } + for (i, v) in [a, b, c, d, e, f, g, hh].enumerated() { h[i] = h[i] &+ v } + } + let digits = Array("0123456789abcdef".utf8) + var out = [UInt8]() + for word in h { + for shift in stride(from: 28, through: 0, by: -4) { out.append(digits[Int((word >> UInt32(shift)) & 0xf)]) } + } + return String(decoding: out, as: UTF8.self) + } + + private static func rotr(_ x: UInt32, _ n: UInt32) -> UInt32 { (x >> n) | (x << (32 - n)) } + + private static let k: [UInt32] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ] +} diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index d16aa5d..5a9dafe 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -84,7 +84,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { do { try await withCollection { try $0.insert(document) } } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } @@ -96,7 +96,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { try sessions.findOne(SessionDocument.self, filter: Self.filter(token: token)) } } catch { - logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) throw InvalidSessionError() } // The TTL monitor only runs about once a minute, so check expiry here too. @@ -121,10 +121,10 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { if matched == 0 { // Expected after a logout when middleware saves at the end of the request. logger.debug("session save matched no session (destroyed or expired); not recreated", - metadata: ["eventid": "\(session.token)"]) + metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } @@ -132,7 +132,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { do { _ = try await withCollection { try $0.deleteOne(filter: Self.filter(token: token)) } } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index b9e3f7f..8984230 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -42,7 +42,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { expires: Double(session.idle), ifNotExists: true) } } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } @@ -52,7 +52,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { do { response = try await withClient { try await $0.get(key: self.key(token)) } } catch { - logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) throw InvalidSessionError() } guard let json = response.string, !json.isEmpty, @@ -83,10 +83,10 @@ public final class RedisSessionDriver: SessionDriver, Sendable { } if reply.isNil { logger.debug("session save matched no session (destroyed or expired); not recreated", - metadata: ["eventid": "\(s.token)"]) + metadata: ["session": "\(PerfectSession.logID(s.token))"]) } } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(s.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(s.token))"]) } } @@ -94,7 +94,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { do { _ = try await withClient { try await $0.delete(keys: self.key(token)) } } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index b0fcdd1..00b9cf8 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -60,7 +60,7 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { } ) } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } @@ -113,7 +113,7 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { } ) } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } @@ -126,7 +126,7 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { doBindings: { stmt in try stmt.bind(position: 1, token) } ) } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 8a682f6..3776400 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -184,6 +184,15 @@ let underThreadSanitizer: Bool = { // MARK: - Tokens + /// logID is the first 12 hex digits of the token's SHA-256. + @Test func logIDIsSHA256Prefix() { + #expect(PerfectSession.sha256Hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + #expect(PerfectSession.sha256Hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + #expect(PerfectSession.sha256Hex(String(repeating: "a", count: 1000)) == + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3") + #expect(PerfectSession.logID("abc") == "ba7816bf8f01") + } + @Test func tokensAre256BitHex() async { let driver = MemorySessionDriver() var seen = Set() diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index a8784d4..fd45171 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import Logging @testable import PerfectSessionMongoDB import PerfectSessionCore import PerfectMongoDB @@ -410,3 +411,44 @@ enum MongoDBLive { let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" #expect(!required || MongoDBLive.mongoEnabled, "SESSION_REQUIRE_LIVE=1 but MONGODB_TESTS is not 1") } + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } +} + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test func mongodbLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + + let driver = try! MongoDBSessionDriver(uri: "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=200&connectTimeoutMS=200") + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 0d057d2..d6ab6f0 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import Logging @testable import PerfectSessionRedis import PerfectSessionCore import PerfectRedis @@ -260,3 +261,45 @@ enum RedisLive { let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" #expect(!required || RedisLive.redisEnabled, "SESSION_REQUIRE_LIVE=1 but REDIS_TESTS is not 1") } + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } +} + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test func redisLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + RedisSessionConnector.host = "127.0.0.1" + RedisSessionConnector.port = 1 // nothing listens: every call fails and logs + let driver = RedisSessionDriver() + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index b64c202..ef75b17 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import Logging @testable import PerfectSessionSQLite import PerfectSessionCore import PerfectSQLite @@ -258,3 +259,44 @@ enum SQLiteLive { let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" #expect(!required || SQLiteLive.sqliteEnabled, "SESSION_REQUIRE_LIVE=1 but SQLITE_TESTS is not 1") } + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } +} + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test func sqliteLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + SQLiteSessionConnector.databasePath = "/nonexistent-dir/sessions.db" // every call fails and logs + let driver = SQLiteSessionDriver() + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} From 271ceeb4fed41a4546b02b281cc88a1a6e599769 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:25:53 -0600 Subject: [PATCH 25/33] Deprecate the config knobs nothing reads; add CSRFSecurity.tokenMatches 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 --- Sources/PerfectSessionCore/CSRFSecurity.swift | 11 +++++++++++ Sources/PerfectSessionCore/PerfectSession.swift | 3 ++- Sources/PerfectSessionCore/SessionConfig.swift | 12 ++++++++++++ .../PerfectSessionCoreTests.swift | 10 ++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index cacac70..2391b8b 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -16,6 +16,17 @@ public struct CSRFSecurity: Sendable { return cleanHost == cleanOrigin } + /// Compares a CSRF token from the request (a header or form field) with the session's + /// (`session.data["csrf"]`) in time that doesn't depend on where they differ. An empty token + /// never matches. + public static func tokenMatches(_ presented: String, _ expected: String) -> Bool { + let a = Array(presented.utf8), b = Array(expected.utf8) + guard !b.isEmpty, a.count == b.count else { return false } + var difference: UInt8 = 0 + for i in 0.. String { if str.hasPrefix("https://") { return String(str.dropFirst(8)) } if str.hasPrefix("http://") { return String(str.dropFirst(7)) } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index accbfca..3f49ca9 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -22,7 +22,8 @@ public struct PerfectSession: @unchecked Sendable { public var ipaddress = "" public var useragent = "" public var _state = "recover" - public var _isOAuth2 = SessionConfig.isOAuth2 + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it, and it isn't stored.") + public var _isOAuth2 = false public init() { let now = Int(Date().timeIntervalSince1970) diff --git a/Sources/PerfectSessionCore/SessionConfig.swift b/Sources/PerfectSessionCore/SessionConfig.swift index 163a0bf..89e1782 100644 --- a/Sources/PerfectSessionCore/SessionConfig.swift +++ b/Sources/PerfectSessionCore/SessionConfig.swift @@ -20,8 +20,11 @@ public struct SessionConfig { nonisolated(unsafe) public static var absoluteTimeout = 0 nonisolated(unsafe) public static var userAgentLock = false nonisolated(unsafe) public static var IPAddressLock = false + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Exclude your health-check path in AuthenticationConfig.") nonisolated(unsafe) public static var healthCheckRoute = "/healthcheck" + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Call your driver's clean() on your own schedule.") nonisolated(unsafe) public static var purgeInterval = 3600 + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it.") nonisolated(unsafe) public static var isOAuth2 = false nonisolated(unsafe) public static var CSRF = CSRFconfig() @@ -29,10 +32,15 @@ public struct SessionConfig { public struct CSRFconfig: Sendable { public init() {} + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Your middleware decides what a failed check does.") public var failAction: CSRFaction = .fail + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.") public var checkState = true + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check Origin/Host with CSRFSecurity.isValid in your middleware.") public var checkHeaders = true + /// Origins accepted by `CSRFSecurity.isValid` besides the request's own host. public var acceptableHostnames = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.") public var requireToken = true } @@ -42,9 +50,13 @@ public struct SessionConfig { /// Accept "*" to allow all origins, or list specific hostnames. public var acceptableHostnames = [String]() /// HTTP method strings, e.g. ["GET", "POST"] + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Methods from your middleware.") public var methods: [String] = ["GET"] + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Headers from your middleware.") public var customHeaders = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Credentials from your middleware.") public var withCredentials = false + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Max-Age from your middleware.") public var maxAge = 0 } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 3776400..f3619e8 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -526,6 +526,16 @@ let underThreadSanitizer: Bool = { #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com", acceptableHostnames: ["trusted.com"])) } + @Test func csrfTokenMatches() { + let token = PerfectSession.newToken() + #expect(CSRFSecurity.tokenMatches(token, token)) + #expect(!CSRFSecurity.tokenMatches(token.uppercased(), token)) + #expect(!CSRFSecurity.tokenMatches(String(token.dropLast()), token)) + #expect(!CSRFSecurity.tokenMatches(token + "0", token)) + #expect(!CSRFSecurity.tokenMatches("", "")) + #expect(!CSRFSecurity.tokenMatches("", token)) + } + // MARK: - CORSSecurity /// A wildcard pattern used to accept any origin that matched its prefix OR its suffix, so From 4f3f42d1e8fbdc960729c9016afd3423974334f2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:26:36 -0600 Subject: [PATCH 26/33] Redis: clamp the TTL so a huge idle doesn't make save() fail 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 --- .../PerfectSessionRedis/RedisSessionDriver.swift | 13 +++++++++++-- .../PerfectSessionRedisTests.swift | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 8984230..0be3a93 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -39,7 +39,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { do { _ = try await withClient { client in try await client.set(key: self.key(session.token), value: .string(json), - expires: Double(session.idle), ifNotExists: true) + expires: Self.ttl(session.idle), ifNotExists: true) } } catch { logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) @@ -79,7 +79,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { let token = s.token, idle = s.idle let reply = try await withClient { client in try await client.set(key: self.key(token), value: .string(json), - expires: Double(idle), ifExists: true) + expires: Self.ttl(idle), ifExists: true) } if reply.isNil { logger.debug("session save matched no session (destroyed or expired); not recreated", @@ -100,6 +100,15 @@ public final class RedisSessionDriver: SessionDriver, Sendable { // MARK: - Private helpers + /// The longest TTL set, in seconds (about 31,700 years). PX takes milliseconds, which for an + /// idle near Int.max neither Perfect-Redis (Int milliseconds) nor Redis (now + PX must fit + /// in 64 bits) accepts; the session's own `idle` is still stored as is. + static let maximumTTL = 1_000_000_000_000 + + static func ttl(_ idle: Int) -> Double { + Double(min(idle, maximumTTL)) + } + private func key(_ token: String) -> String { RedisSessionConnector.keyPrefix + token } diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index d6ab6f0..5a6ff7a 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -89,6 +89,21 @@ enum RedisLive { } } + /// An idle time near Int.max made SET's PX argument unrepresentable, so save() failed and + /// the session's new data was lost. + @Test(arguments: [Int.max, Int.max / 1000, 1 << 50]) + func hugeIdleStillSaves(_ idle: Int) async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = idle + session.data["saved"] = true + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.data["saved"] as? Bool == true) + #expect(resumed.idle == idle) + await driver.destroy(token: session.token) + } + /// An idle time of 0 or less used to store the session with no TTL at all. @Test func nonPositiveIdleDoesNotMakeASessionImmortal() async throws { let driver = getDriver() From a19e6164b75c9840ca2b6e9bafc4eefb48015d19 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:28:05 -0600 Subject: [PATCH 27/33] Redis: commandTimeout defaults to 5 seconds 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 --- .../RedisSessionDriver.swift | 8 +- .../PerfectSessionRedisTests.swift | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 0be3a93..19491a3 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -13,9 +13,11 @@ public struct RedisSessionConnector: Sendable { /// Prepended to each token to make its Redis key. Tokens come from clients' cookies, so the /// prefix keeps a token from naming one of the application's own keys. nonisolated(unsafe) public static var keyPrefix: String = "perfect-session:" - /// Seconds each Redis call may take (see Perfect-Redis's `RedisClientIdentifier`), or nil to - /// wait indefinitely. - nonisolated(unsafe) public static var commandTimeout: Double? = nil + /// Seconds each Redis call may take, 5 by default, or nil to wait indefinitely. A call that + /// runs out throws (the driver logs it, and `resume` throws `InvalidSessionError`), and its + /// connection is closed; see Perfect-Redis's `RedisClientIdentifier.commandTimeout`. It also + /// bounds connecting, after the host name lookup. + nonisolated(unsafe) public static var commandTimeout: Double? = 5 private init() {} } diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 5a6ff7a..547e58e 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -1,6 +1,11 @@ import Testing import Foundation import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionRedis import PerfectSessionCore import PerfectRedis @@ -89,6 +94,40 @@ enum RedisLive { } } + /// A Redis call that gets no reply gives up after RedisSessionConnector.commandTimeout, + /// 5 seconds by default (it used to wait forever). + @Test func commandTimeoutDefaultsToFiveSeconds() async throws { + #expect(RedisSessionConnector.commandTimeout == 5) + let driver = getDriver() + let listener = try SilentListener() + defer { listener.close() } + RedisSessionConnector.host = "127.0.0.1" + RedisSessionConnector.port = listener.port + defer { _ = getDriver() } + let clock = ContinuousClock() + let start = clock.now + let outcome = await withTaskGroup(of: String.self) { group in + group.addTask { + do { + _ = try await driver.resume(token: "anything") + return "resumed" + } catch { + return "threw" + } + } + group.addTask { + try? await Task.sleep(for: .seconds(15)) + return "still waiting" + } + let first = await group.next() ?? "" + group.cancelAll() + return first + } + let elapsed = clock.now - start + #expect(outcome == "threw") + #expect(elapsed >= .seconds(4) && elapsed < .seconds(9), "\(elapsed)") + } + /// An idle time near Int.max made SET's PX argument unrepresentable, so save() failed and /// the session's new data was lost. @Test(arguments: [Int.max, Int.max / 1000, 1 << 50]) @@ -318,3 +357,42 @@ struct CapturingLogHandler: LogHandler { exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) } } + +/// A TCP listener that never accepts or answers: connections complete in the kernel's backlog, +/// then nothing is ever sent back. +final class SilentListener: @unchecked Sendable { + let fd: Int32 + let port: Int + + struct Failure: Error {} + + init() throws { + #if os(Linux) + let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + #else + let fd = socket(AF_INET, SOCK_STREAM, 0) + #endif + guard fd >= 0 else { throw Failure() } + self.fd = fd + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = 0 + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, length) == 0 && listen(fd, 16) == 0 && getsockname(fd, $0, &length) == 0 + } + } + guard bound else { throw Failure() } + port = Int(UInt16(bigEndian: address.sin_port)) + } + + func close() { + #if os(Linux) + _ = Glibc.close(fd) + #else + _ = Darwin.close(fd) + #endif + } +} From 10a030fe4e0fc8f295180b72a30ba8af52fa49d3 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 09:28:31 -0600 Subject: [PATCH 28/33] SessionCookie: recognize __Host-/__Secure- prefixes in any case 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 --- Sources/PerfectSessionCore/SessionCookie.swift | 8 +++++--- .../PerfectSessionCoreTests.swift | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectSessionCore/SessionCookie.swift b/Sources/PerfectSessionCore/SessionCookie.swift index 0ee67cb..407733d 100644 --- a/Sources/PerfectSessionCore/SessionCookie.swift +++ b/Sources/PerfectSessionCore/SessionCookie.swift @@ -9,7 +9,7 @@ public enum SessionCookie { /// drops it when it closes; the server enforces `idle` and `absoluteTimeout` itself. /// /// `SameSite=None` always adds `Secure` (browsers reject it otherwise), and so does a name - /// starting with `__Secure-` or `__Host-`. Returns nil, rather than a header a browser would + /// starting with `__Secure-` or `__Host-` (in any case). Returns nil, rather than a header a browser would /// misread, if the name isn't a valid cookie name, the token or path holds a character a cookie /// can't (`;`, `,`, whitespace, quotes, a backslash or a control character), the domain isn't /// a plain host name, or a `__Host-` cookie has a domain or a path other than `/`. @@ -30,10 +30,12 @@ public enum SessionCookie { guard isToken(name), value.unicodeScalars.allSatisfy(isCookieOctet), !path.isEmpty, path.unicodeScalars.allSatisfy(isPathCharacter), domain.isEmpty || isHostName(domain) else { return nil } - let hostPrefixed = name.hasPrefix("__Host-") + // Browsers recognize the prefixes ignoring case. + let lowerName = name.lowercased() + let hostPrefixed = lowerName.hasPrefix("__host-") if hostPrefixed && (!domain.isEmpty || path != "/") { return nil } let secure = SessionConfig.cookieSecure || SessionConfig.cookieSameSite == .none - || hostPrefixed || name.hasPrefix("__Secure-") + || hostPrefixed || lowerName.hasPrefix("__secure-") var parts = ["\(name)=\(value)", "Path=\(path)"] if !domain.isEmpty { parts.append("Domain=\(domain)") } if let extra { parts.append(extra) } diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index f3619e8..db5f137 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -616,6 +616,16 @@ let underThreadSanitizer: Bool = { withCookieConfig(name: "__Host-sid", path: "/app") { #expect(SessionCookie.setCookie(token: "t") == nil) } + // Browsers match the prefixes ignoring case. + withCookieConfig(name: "__HOST-sid", domain: "example.com") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + withCookieConfig(name: "__host-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } + withCookieConfig(name: "__SECURE-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__SECURE-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } } /// Settings or a token containing ";" or CR/LF would otherwise add attributes or headers. From ede3dab430d630184245268c47c2e2faeb226cfb Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:29:18 -0500 Subject: [PATCH 29/33] CSRFSecurity.isValid: compare scheme, default ports and case 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 --- Sources/PerfectSessionCore/CSRFSecurity.swift | 56 ++++++++++++++++--- .../PerfectSessionCoreTests.swift | 21 ++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index 2391b8b..8f3d4c3 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -4,16 +4,56 @@ import Foundation /// values from the request and passes them as strings to these helpers. public struct CSRFSecurity: Sendable { - /// True if **origin** (scheme stripped) is in **acceptableHostnames** (by default - /// `SessionConfig.CSRF.acceptableHostnames`) or equals **host**. + /// True if **origin** (the request's `Origin` header) names the same origin as **host** (its + /// `Host` header) or as an entry in **acceptableHostnames** (by default + /// `SessionConfig.CSRF.acceptableHostnames`). + /// + /// Comparison ignores case and treats a default port (`:443` for https, `:80` for http) like + /// no port. When both sides give a scheme, the schemes must match; a side without a scheme + /// (a `Host` header never has one) takes the other side's. An empty or `null` origin never + /// matches. public static func isValid(origin: String, host: String, acceptableHostnames: [String] = SessionConfig.CSRF.acceptableHostnames) -> Bool { - let cleanOrigin = stripScheme(origin) - if cleanOrigin.isEmpty { return false } - if acceptableHostnames.contains(cleanOrigin) { return true } - let cleanHost = stripScheme(host) - if cleanHost.isEmpty { return false } - return cleanHost == cleanOrigin + let o = Endpoint(origin) + guard !o.authority.isEmpty, o.authority != "null" || o.scheme != nil else { return false } + for accepted in acceptableHostnames where Endpoint(accepted).matches(o) { return true } + let h = Endpoint(host) + guard !h.authority.isEmpty else { return false } + return h.matches(o) + } + + /// An origin or host: an optional scheme and a lowercased host[:port]. + struct Endpoint { + let scheme: String? + let authority: String + + init(_ text: String) { + var rest = Substring(text.lowercased()) + if let range = rest.range(of: "://") { + scheme = String(rest[.. Bool { + if let a = scheme, let b = other.scheme, a != b { return false } + let effective = scheme ?? other.scheme + return Self.strip(authority, effective) == Self.strip(other.authority, effective) + } + + static func strip(_ authority: String, _ scheme: String?) -> String { + let suffix: String + switch scheme { + case "https": suffix = ":443" + case "http": suffix = ":80" + default: return authority + } + return authority.hasSuffix(suffix) ? String(authority.dropLast(suffix.count)) : authority + } } /// Compares a CSRF token from the request (a header or form field) with the session's diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index db5f137..dd33b5a 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -518,7 +518,26 @@ let underThreadSanitizer: Bool = { } @Test func csrfSecurityStripsScheme() { - #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "http://example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "https://example.com")) + } + + /// The check ignored the scheme (an http origin passed for an https host), treated + /// example.com:443 and example.com as different, and compared the Host header's case. + @Test func csrfSecurityComparesSchemePortAndCase() { + #expect(!CSRFSecurity.isValid(origin: "http://example.com", host: "https://example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:443")) + #expect(CSRFSecurity.isValid(origin: "https://example.com:443", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "http://example.com", host: "example.com:80")) + #expect(!CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:80")) + #expect(!CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com:8443")) + #expect(CSRFSecurity.isValid(origin: "https://Example.COM", host: "EXAMPLE.com")) + #expect(!CSRFSecurity.isValid(origin: "null", host: "null")) + #expect(CSRFSecurity.isValid(origin: "https://trusted.com:443", host: "other.com", + acceptableHostnames: ["https://Trusted.com"])) + #expect(!CSRFSecurity.isValid(origin: "http://trusted.com", host: "other.com", + acceptableHostnames: ["https://trusted.com"])) } @Test func csrfSecurityAcceptableHostnames() { From d0c267ed87f0103b63d03d8bb008e81b31185059 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:31:53 -0500 Subject: [PATCH 30/33] clean(), the MongoDB TTL index and Redis TTLs honour absoluteTimeout 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 --- .../MemorySessionDriver.swift | 7 ++++- .../PerfectSessionCore/PerfectSession.swift | 11 +++++--- .../MongoDBSessionDriver.swift | 26 +++++++++++++++---- .../MySQLSessionDriver.swift | 19 ++++++++++---- .../PostgreSQLSessionDriver.swift | 19 ++++++++++---- .../RedisSessionDriver.swift | 16 +++++++----- .../SQLiteSessionDriver.swift | 15 +++++++++-- .../PerfectSessionCoreTests.swift | 24 +++++++++++++++++ .../PerfectSessionMongoDBTests.swift | 21 +++++++++++++++ .../PerfectSessionMySQLTests.swift | 16 ++++++++++++ .../PerfectSessionPostgreSQLTests.swift | 16 ++++++++++++ .../PerfectSessionRedisTests.swift | 13 ++++++++++ .../PerfectSessionSQLiteTests.swift | 16 ++++++++++++ 13 files changed, 192 insertions(+), 27 deletions(-) diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index ab0a6a0..ceac80d 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -42,10 +42,15 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { _ = lock.withLock { sessions.removeValue(forKey: token) } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { let now = Int(Date().timeIntervalSince1970) lock.withLock { - sessions = sessions.filter { _, s in s.expires > now } + sessions = sessions.filter { _, s in !s.isExpired(at: now, absoluteTimeout: absoluteTimeout) } } } } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index 3f49ca9..a0af10f 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -86,10 +86,15 @@ public struct PerfectSession: @unchecked Sendable { /// Every driver's `resume` refuses an expired session. public func isExpired(at now: Int = Int(Date().timeIntervalSince1970), absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Bool { - if expires <= now { return true } - guard absoluteTimeout > 0 else { return false } + expiry(absoluteTimeout: absoluteTimeout) <= now + } + + /// When the session expires (seconds since 1970), taking **absoluteTimeout** into account: + /// the earlier of `expires` and `created + absoluteTimeout` (when that's positive). + public func expiry(absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Int { + guard absoluteTimeout > 0 else { return expires } let (end, overflow) = created.addingReportingOverflow(absoluteTimeout) - return !overflow && end <= now + return overflow ? expires : min(expires, end) } /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. The locks diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index 5a9dafe..7c12daa 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -19,7 +19,7 @@ public struct MongoDBSessionConnector: Sendable { /// data: { ... }, expiresAt: } /// /// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see -/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB +/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`, or `created + absoluteTimeout` if earlier), so MongoDB /// deletes expired sessions on its own; `clean()` still deletes them immediately for callers /// that rely on it. Like the other drivers, `resume` refuses an expired session. /// @@ -136,14 +136,29 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { let now = Date() + let cutoff = absoluteTimeout > 0 ? Int(now.timeIntervalSince1970) - absoluteTimeout : nil do { try await withCollection { sessions in let before = BSON() before.append(key: "$lt", dateTime: Self.millis(now)) - let filter = BSON() - filter.append(key: "expiresAt", document: before) + let expired = BSON() + expired.append(key: "expiresAt", document: before) + guard let cutoff else { + try sessions.deleteMany(filter: expired) + return + } + // Documents saved before absoluteTimeout was set can have a later expiresAt. + let filter = try BSON(json: """ + {"$or": [{"expiresAt": {"$lt": {"$date": {"$numberLong": "\(Self.millis(now))"}}}}, + {"created": {"$lte": \(cutoff)}}]} + """) try sessions.deleteMany(filter: filter) } } catch { @@ -209,7 +224,7 @@ struct SessionDocument: Codable, Sendable { var data: JSONValue var expiresAt: Date - init(_ session: PerfectSession) { + init(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout) { _id = session.token userid = session.userid created = session.created @@ -220,7 +235,8 @@ struct SessionDocument: Codable, Sendable { // Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber. data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:])) .mappingKeys(Self.escape) - expiresAt = Date(timeIntervalSince1970: TimeInterval(min(max(session.expires, Self.earliestExpiry), Self.latestExpiry))) + expiresAt = Date(timeIntervalSince1970: TimeInterval(min(max(session.expiry(absoluteTimeout: absoluteTimeout), + Self.earliestExpiry), Self.latestExpiry))) } /// BSON dates are 64-bit milliseconds, and Perfect-MongoDB's encoder traps on a `Date` outside diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 555a78a..c0b6418 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -91,12 +91,21 @@ public final class MySQLSessionDriver: SessionDriver, Sendable { exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE token=?", params: [token]) } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { - exec( - // DECIMAL arithmetic: a BIGINT sum overflows (an error) for an idle near Int.max. - "DELETE FROM `\(MySQLSessionConnector.table)` WHERE CAST(updated AS DECIMAL(30,0)) + idle < ?", - params: [Int(Date().timeIntervalSince1970)] - ) + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { + let now = Int(Date().timeIntervalSince1970) + // DECIMAL arithmetic: a BIGINT sum overflows (an error) for an idle near Int.max. + let idle = "CAST(updated AS DECIMAL(30,0)) + idle < ?" + if absoluteTimeout > 0 { + exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle) OR CAST(created AS DECIMAL(30,0)) + ? <= ?", + params: [now, absoluteTimeout, now]) + } else { + exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle)", params: [now]) + } } // MARK: - Private helpers diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index f1976ea..b49feb2 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -107,12 +107,21 @@ public final class PostgreSQLSessionDriver: SessionDriver, Sendable { exec("DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE token=$1", params: [token]) } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { - exec( - // numeric arithmetic: an integer sum overflows (an error) for an idle near the type's max. - "DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE updated::numeric + idle < $1", - params: [Int(Date().timeIntervalSince1970)] - ) + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { + let now = Int(Date().timeIntervalSince1970) + // numeric arithmetic: an integer sum overflows (an error) for an idle near the type's max. + let table = "\"\(PostgreSQLSessionConnector.table)\"" + if absoluteTimeout > 0 { + exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1 OR created::numeric + $2 <= $1", + params: [now, absoluteTimeout]) + } else { + exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1", params: [now]) + } } // MARK: - Private helpers diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 19491a3..4678df7 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -41,7 +41,7 @@ public final class RedisSessionDriver: SessionDriver, Sendable { do { _ = try await withClient { client in try await client.set(key: self.key(session.token), value: .string(json), - expires: Self.ttl(session.idle), ifNotExists: true) + expires: Self.ttl(session), ifNotExists: true) } } catch { logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) @@ -73,15 +73,15 @@ public final class RedisSessionDriver: SessionDriver, Sendable { var s = session s.touch() do { - guard s.idle > 0 else { + guard !s.isExpired() else { // idle of 0 or less, or past absoluteTimeout _ = try await withClient { try await $0.delete(keys: self.key(s.token)) } return } guard let json = encode(s) else { return } - let token = s.token, idle = s.idle + let token = s.token, ttl = Self.ttl(s) let reply = try await withClient { client in try await client.set(key: self.key(token), value: .string(json), - expires: Self.ttl(idle), ifExists: true) + expires: ttl, ifExists: true) } if reply.isNil { logger.debug("session save matched no session (destroyed or expired); not recreated", @@ -107,8 +107,12 @@ public final class RedisSessionDriver: SessionDriver, Sendable { /// in 64 bits) accepts; the session's own `idle` is still stored as is. static let maximumTTL = 1_000_000_000_000 - static func ttl(_ idle: Int) -> Double { - Double(min(idle, maximumTTL)) + /// The key's TTL in seconds: until the session's `expiry(absoluteTimeout:)`, at most + /// `maximumTTL`, at least 1 (save() deletes a session already past it). + static func ttl(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout, + now: Int = Int(Date().timeIntervalSince1970)) -> Double { + let (remaining, overflow) = session.expiry(absoluteTimeout: absoluteTimeout).subtractingReportingOverflow(now) + return Double(max(1, min(overflow ? maximumTTL : remaining, maximumTTL))) } private func key(_ token: String) -> String { diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 00b9cf8..5ccb4e7 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -130,14 +130,25 @@ public final class SQLiteSessionDriver: SessionDriver, Sendable { } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { do { let db = try SQLite(SQLiteSessionConnector.databasePath) defer { db.close() } let now = Int(Date().timeIntervalSince1970) + // A positive absolute timeout also deletes sessions created at or before now - timeout + // (SQLite turns an overflowing integer sum into a real, so neither side overflows). + let cutoff = absoluteTimeout > 0 ? now - absoluteTimeout : Int.min try db.execute( - statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ?", - doBindings: { stmt in try stmt.bind(position: 1, now) } + statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ? OR created <= ?", + doBindings: { stmt in + try stmt.bind(position: 1, now) + try stmt.bind(position: 2, cutoff) + } ) } catch { logger.error("session clean failed: \(error)") diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index dd33b5a..d58aade 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -402,6 +402,30 @@ let underThreadSanitizer: Bool = { await driver.destroy(token: fresh.token) } + @Test func memoryCleanHonoursAbsoluteTimeout() async throws { + let driver = MemorySessionDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: old.token) + } + _ = try await driver.resume(token: young.token) + } + + @Test func expiryTakesTheEarlierLimit() { + var s = PerfectSession() + s.created = 1000 + s.updated = 5000 + s.idle = 100 + #expect(s.expiry(absoluteTimeout: 0) == 5100) + #expect(s.expiry(absoluteTimeout: 3000) == 4000) + #expect(s.expiry(absoluteTimeout: 10000) == 5100) + #expect(s.expiry(absoluteTimeout: Int.max) == 5100) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index fd45171..d657882 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -346,6 +346,27 @@ enum MongoDBLive { await driver.destroy(token: fresh.token) } + /// clean() and the TTL index used to ignore SessionConfig.absoluteTimeout. + @Test func cleanAndTTLHonourAbsoluteTimeout() async throws { + let driver = try getDriver() + var old = await driver.create() + old.created -= 1000 + #expect(SessionDocument(old, absoluteTimeout: 500).expiresAt == + Date(timeIntervalSince1970: TimeInterval(old.created + 500))) + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + func stored(_ token: String) async throws -> Int { + try await driver.pool.withClient { client in + try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_test") + .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#)) + } + } + #expect(try await stored(old.token) == 0) + #expect(try await stored(young.token) == 1) + await driver.destroy(token: young.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = try getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 2751f30..4756e67 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -251,6 +251,22 @@ enum MySQLLive { await driver.destroy(token: fresh.token) } + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(isStored(old.token))) + #expect(isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(isStored(young.token)) + await driver.destroy(token: young.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index d60fdba..71e7e17 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -257,6 +257,22 @@ enum PostgreSQLLive { await driver.destroy(token: fresh.token) } + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(isStored(old.token))) + #expect(isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(isStored(young.token)) + await driver.destroy(token: young.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 547e58e..3da1fdb 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -297,6 +297,19 @@ enum RedisLive { await driver.destroy(token: fresh.token) } + /// The key's TTL used to ignore SessionConfig.absoluteTimeout. + @Test func ttlHonoursAbsoluteTimeout() { + var s = PerfectSession() + s.created = 10_000 + s.updated = 10_100 + s.idle = 3600 + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == 3600) + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 500, now: 10_100) == 400) + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 50, now: 10_100) == 1) + s.idle = Int.max + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == Double(RedisSessionDriver.maximumTTL)) + } + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. @Test func serverIsReachable() async throws { _ = getDriver() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index ef75b17..49f1da0 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -221,6 +221,22 @@ enum SQLiteLive { await driver.destroy(token: fresh.token) } + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(try isStored(old.token))) + #expect(try isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(try isStored(young.token)) + await driver.destroy(token: young.token) + } + @Test func regenerateMovesSessionToNewToken() async throws { let driver = await getDriver() var session = await driver.create() From 8073d21a346beb4e02a6cdbf8d00998b25f3da76 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:32:58 -0500 Subject: [PATCH 31/33] README and docs: review fixes, CSRF and auth middleware example - 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 --- README.md | 102 +++++++++++++++--- Sources/PerfectSessionCore/CSRFSecurity.swift | 5 +- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2be09da..e086ef2 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ needs their libraries. - `SessionConfig`, for cookie, expiry, CSRF and CORS settings; - the `SessionDriver` protocol and `MemorySessionDriver`, an in-process store for development and tests; - `SessionCookie`, which builds the `Set-Cookie` values for the session cookie from `SessionConfig`; -- `AuthFilter` (path inclusion/exclusion rules), and `CSRFSecurity`/`CORSSecurity` (origin and host checks on - header strings). +- `AuthFilter` (which paths require authentication), and `CSRFSecurity`/`CORSSecurity` (CSRF token, origin + and host checks on header strings). None of these depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver, and applies the checks. @@ -73,20 +73,43 @@ and applies the checks. - **Tokens.** Session and CSRF tokens are 32 bytes from the system's secure random number generator, as 64 hex digits (`PerfectSession.newToken()`). - **Expiry is enforced by the server.** Every driver's `resume` throws `InvalidSessionError` for a session that has - been idle for `idle` seconds (`SessionConfig.idle`, one day by default; each `save` renews it), or, if you set - `SessionConfig.absoluteTimeout`, that many seconds after it was created. Times are seconds since 1970, so the - process's time zone doesn't matter. `clean()` deletes expired sessions from storage. + gone `idle` seconds (`SessionConfig.idle`, one day by default) without a `save` (every driver's `save` renews it), + or, if you set `SessionConfig.absoluteTimeout`, that many seconds after it was created, however active it is. + Times are seconds since 1970, so the process's time zone doesn't matter. `clean()` deletes expired sessions from + storage (both limits); the MongoDB TTL index and Redis key TTLs follow both limits too. - **Fixation.** `resume` only returns sessions the driver stored, and `save` only updates a stored session (so a save racing a logout can't bring it back). Call `regenerate(_:)` when a user signs in or gains privileges, and - send the client the new token: it moves the session to a new token and CSRF token and destroys the old one. + send the client the new token: it moves the session (user, data, idle time and creation time, so the absolute + timeout keeps running) to a new token and CSRF token and destroys the old one. +- **CSRF.** Every session has a CSRF token in `data["csrf"]` (`resume` adds one if a stored session lacks it). + Send it to your pages, have state-changing requests send it back (a header or form field), and compare with + `CSRFSecurity.tokenMatches(_:_:)`, which takes the same time wherever the tokens differ. Also check the `Origin` + header with `CSRFSecurity.isValid(origin:host:)`: it compares scheme, host and port, ignoring case and default + ports, and never accepts an empty or `null` origin. +- **Authentication paths.** `AuthFilter.requiresAuthentication(_:using:)` says whether a request path needs a + signed-in user. Paths are compared by `/`-separated segment, ignoring empty and `.` segments and (by default, + `AuthenticationConfig.caseInsensitive`) ASCII case, because Perfect-NIO 0.1 routes `/ADMIN` to an `/admin` + handler. `include("/admin")` covers `/admin` and everything below it, but not `/administrator`; `"/admin/*"` + means the same, and `"*"` (or `"/"`) covers every path. Exclusions override inclusions. `*` is a wildcard only as + a whole final segment: an inclusion like `"*.json"` or `"/admin*"` makes every path require authentication, and + such an exclusion matches nothing. When anything is protected, a path with a `..` segment, `%`, `;` or `\` + always requires authentication, since servers decode those differently. Pass the path without its query string. + (`shouldWeAccept`, which returns true when authentication is *required*, is deprecated.) +- **CORS.** `CORSSecurity.isAcceptable(origin:)` matches `SessionConfig.CORS.acceptableHostnames` ignoring case; + each `*` in a pattern matches any run of characters. Anchor patterns with the scheme and the full host: + a trailing `*`, as in `"https://example.com*"`, also matches `https://example.com.evil.net`. `"*"` accepts every + origin, `null` included. Sending the `Access-Control-*` headers, and deciding whether to allow credentials, is up + to your middleware. - **Cookies.** `SessionCookie.setCookie(token:)` and `expiredCookie()` apply `SessionConfig`'s `name`, `cookiePath`, `cookieDomain`, `cookieSecure`, `cookieHTTPOnly` (default on) and `cookieSameSite` (default - `Strict`). `SameSite=None` and the `__Secure-`/`__Host-` name prefixes always add `Secure`; set `cookieSecure` + `Strict`). `SameSite=None` and the `__Secure-`/`__Host-` name prefixes (in any case) always add `Secure`; set `cookieSecure` yourself when you serve over HTTPS. They return nil instead of a malformed header if a setting or the token holds a character a cookie can't. - **Session data** is stored as JSON. Keep `data` to JSON values (`String`, `Bool`, finite numbers, `NSNull`, and - arrays and `[String: …]` dictionaries of them); if it holds anything else, such as a `Date`, the session is saved - with empty data. + arrays and `[String: …]` dictionaries of them). A top-level key whose value is anything else, such as a `Date`, + isn't saved, and its name (never its value) is logged; the other keys and the CSRF token are saved. +- **Logs** identify a session by `PerfectSession.logID(_:)`, a 12-digit SHA-256 prefix of its token, never by the + token itself. - **Configuration** (`SessionConfig`, `AuthFilter.authenticationConfig` and each `…SessionConnector`) is global state: set it once at startup, before serving requests. The checks also take explicit values (`isValid(…, ipAddressLock:userAgentLock:)`, `CSRFSecurity.isValid(…, acceptableHostnames:)`, @@ -127,6 +150,36 @@ let routes: Routes = root(loadSession).map { state -> H } ``` +Authentication and CSRF checks, after `loadSession`: + +``` swift +let auth: AuthenticationConfig = { + var config = AuthenticationConfig() + config.include("/account") + config.exclude("/account/login") + return config +}() + +@Sendable func checkRequest(_ state: RequestSession, _ req: any HTTPRequest) throws -> RequestSession { + if AuthFilter.requiresAuthentication(req.path, using: auth), state.session.userid.isEmpty { + throw ErrorOutput(status: .unauthorized, description: "Sign in first") + } + if req.method != .GET && req.method != .HEAD { + // The page sent the session's data["csrf"] back in a header (or a form field). + let expected = state.session.data["csrf"] as? String ?? "" + let presented = req.headers["X-CSRF-Token"].first ?? "" + guard CSRFSecurity.tokenMatches(presented, expected), + CSRFSecurity.isValid(origin: req.headers["Origin"].first ?? "", + host: req.headers["Host"].first ?? "") else { + throw ErrorOutput(status: .forbidden, description: "CSRF check failed") + } + } + return state +} + +let checked = root(loadSession).request(checkRequest) +``` + ## Storage backends Each backend is a target and product in this package, with its own test suite. Configure a backend through its @@ -151,8 +204,10 @@ name, password and database. The **Redis** backend stores each session as JSON under `RedisSessionConnector.keyPrefix` + token (default prefix `perfect-session:`, so a client's token can't name your application's own keys), with a TTL of the session's -`idle`, renewed by each `save`. Each call opens its own connection. `RedisSessionConnector.commandTimeout` (seconds, -default nil: no limit) is passed to Perfect-Redis; set it so a stalled server can't hold requests indefinitely. +`idle` (or until the absolute timeout, if sooner; at most about 31,700 years), renewed by each `save`. Each call +opens its own connection. `RedisSessionConnector.commandTimeout` (seconds, default 5; nil waits indefinitely) is +passed to Perfect-Redis: a call that takes longer throws, its connection is closed, and the driver logs the error +(`resume` throws `InvalidSessionError`). The **MongoDB** backend stores each session as a document keyed by its token, with `data` as a subdocument. Its `setup()` adds a TTL index, so MongoDB deletes expired sessions itself. Besides `MongoDBSessionConnector` @@ -195,8 +250,14 @@ the run instead of skipping. - **NUL in user IDs and user agents.** PostgreSQL text can't hold a NUL character (the write fails, so the session isn't stored or saved), and SQLite's text binding stops at it. Session `data` is unaffected (it's JSON). -- **`AuthFilter` matches strings exactly.** An inclusion of `/admin` doesn't cover `/admin/` or `/Admin`, and - `/admin/*` doesn't cover `/admin`. Normalize paths before checking them, or list each form. +- **`AuthFilter` sees only the path you pass.** It doesn't percent-decode (a path with `%` requires authentication + when anything is protected), and with `caseInsensitive` off it assumes your router is case-sensitive too. +- **No TLS settings for MySQL or PostgreSQL.** `MySQLSessionConnector` and `PostgreSQLSessionConnector` have no + TLS/`sslmode` options, so the connection uses the client library's defaults (libpq's is `prefer`, which doesn't + verify the server). Use a trusted network, a local socket, or a TLS tunnel. +- **Database errors aren't logged by the MySQL and PostgreSQL drivers**: a failed write is silent. +- **MySQL compares tokens with the column's collation**, case-insensitively by default; tokens are lowercase hex, so + this only means an uppercased token also resumes the session. - **Global configuration.** The settings are `nonisolated(unsafe)` statics: changing them while requests are being served is a data race. - **`PerfectSession.data` is `[String: Any]`.** `PerfectSession` is `@unchecked Sendable` on the understanding that @@ -212,6 +273,17 @@ replaced by this package: - Depend on `PerfectSessionCore` plus a backend product, instead of `Perfect-Session` and a `Perfect-Session-` package. +- `PerfectSessionManager` is now `SessionDriver`, an `async` `Sendable` protocol that also requires + `create(ipaddress:useragent:)` (and has `setup`, `clean` and `regenerate`, with default implementations). +- `SessionConfig.couchDatabase` and `mongoCollection` are gone: configure the backend's `…SessionConnector` + (`MongoDBSessionConnector.collection`). `cookieSameSite` is a `CookieSameSite` (was `HTTPCookie.SameSite`), and + `CORS.methods` is `[String]` (was `[HTTPMethod]`). +- These settings still exist but have no effect in 4.0 and are deprecated: `SessionConfig.CSRF.failAction`, + `checkState`, `checkHeaders` and `requireToken`; `SessionConfig.CORS.methods`, `customHeaders`, + `withCredentials` and `maxAge`; `SessionConfig.purgeInterval`, `healthCheckRoute` and `isOAuth2`; + `PerfectSession._isOAuth2`; `AuthenticationConfig.denied`. Your middleware does what they used to configure. +- `AuthFilter.shouldWeAccept` is deprecated for `requiresAuthentication`, which matches by path segment, ignoring + case (see [Session security](#session-security)). - There are no HTTP filters. Write a small middleware for your server (see [Using it with Perfect-NIO](#using-it-with-perfect-nio)) that calls a `SessionDriver`, `SessionCookie`, `CSRFSecurity`, `CORSSecurity` and `AuthFilter`. @@ -232,6 +304,10 @@ If you used `main` before 4.0.0: - **Redis TTLs follow the session's `idle`** rather than `SessionConfig.idle`, and a session whose `idle` is 0 or less is deleted rather than stored without a TTL. - **`MemorySessionDriver.save` only updates** a session it has stored, like the other drivers. +- **Every `save` renews the idle time** (only Redis did); `regenerate` keeps the creation time. +- **Redis calls time out after 5 seconds** by default (`RedisSessionConnector.commandTimeout`; set nil for none). +- **Session data JSON can't hold** loses only the offending keys, not all of `data`. +- **`AuthFilter`** matches by segment, ignoring case; `CSRFSecurity.isValid` compares scheme and port. - **Tokens are 64 hex digits** (they were UUIDs). - **Wider columns.** Tables that `setup()` created before 4.0 keep 32-bit `created`/`updated`/`idle` columns, which can't hold an `idle` past about 68 years or times after 2038. Widen them: diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index 8f3d4c3..86f7a74 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -80,7 +80,10 @@ public struct CORSSecurity: Sendable { /// True if **origin** matches an entry in **config**'s `acceptableHostnames` (by default /// `SessionConfig.CORS`'s), ignoring /// case. `"*"` accepts every origin; in any other entry each `*` matches any run of - /// characters, and the rest must match in full (`"https://*.example.com"`). + /// characters, and the rest must match in full (`"https://*.example.com"`). Anchor patterns + /// with the scheme and the whole host: a trailing `*` (`"https://example.com*"`) also matches + /// `https://example.com.evil.net`, and `"*"` accepts any origin, `null` included. Sending the + /// `Access-Control-*` headers, credentials included, is the caller's job. public static func isAcceptable(origin: String, config: SessionConfig.CORSconfig = SessionConfig.CORS) -> Bool { guard config.enabled, !config.acceptableHostnames.isEmpty else { return false From 5371df558c8eb1dfce959ac504824fc855913c90 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:35:02 -0500 Subject: [PATCH 32/33] README.zh_CN: CSRF, AuthFilter, save renewal, Redis 5 s timeout Co-Authored-By: Claude Opus 5.5 --- README.zh_CN.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.zh_CN.md b/README.zh_CN.md index 2e7129f..8b4cc4e 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -64,14 +64,19 @@ targets: [ ### 会话安全 - **令牌**:会话令牌和 CSRF 令牌取自系统的安全随机数生成器(32 字节,64 个十六进制字符)。 -- **过期由服务器强制执行**:会话空闲超过 `idle` 秒(每次 `save` 都会续期),或设置了 +- **过期由服务器强制执行**:会话空闲超过 `idle` 秒(所有驱动的 `save` 都会续期),或设置了 `SessionConfig.absoluteTimeout` 且自创建起已超过该秒数时,所有驱动的 `resume` 都会抛出 `InvalidSessionError`。 时间以 1970 年以来的秒数存储,与进程时区无关。 - **会话固定**:`resume` 只返回驱动已存储的会话,`save` 只更新已存储的会话。用户登录或权限变化时调用 `regenerate(_:)`,并把新令牌发给客户端。 - **Cookie**:`SessionCookie.setCookie(token:)` 与 `expiredCookie()` 使用 `SessionConfig` 的 Cookie 设置 (默认 `HttpOnly`、`SameSite=Strict`);`SameSite=None` 和 `__Secure-`/`__Host-` 前缀总会加上 `Secure`。 -- **会话数据**以 JSON 存储,`data` 只能存放 JSON 值;否则会话以空数据保存。 +- **CSRF**:用 `CSRFSecurity.tokenMatches(_:_:)` 比较请求带回的令牌与 `data["csrf"]`,并用 + `CSRFSecurity.isValid(origin:host:)` 检查 `Origin`(比较协议、主机和端口,忽略大小写和默认端口)。 +- **需要登录的路径**:`AuthFilter.requiresAuthentication(_:using:)` 按 `/` 分段、默认忽略大小写进行匹配; + `"/admin"` 覆盖其下所有路径但不覆盖 `/administrator`。`shouldWeAccept` 已弃用。 +- **会话数据**以 JSON 存储,`data` 只能存放 JSON 值;无法存为 JSON 的键不会被保存(只记录键名),其他键和 CSRF + 令牌照常保存。日志中只记录令牌的 SHA-256 前缀(`PerfectSession.logID`),不记录令牌本身。 - **配置**是全局状态,请在启动时设置一次,然后再开始处理请求。 ## 存储后端 @@ -86,7 +91,7 @@ targets: [ **MySQL、PostgreSQL 与 SQLite** 后端每个会话一行,所有值都以参数绑定;`created`、`updated`、`idle` 为 64 位列。 **Redis** 后端把会话以 JSON 存在 `RedisSessionConnector.keyPrefix`(默认 `perfect-session:`)加令牌的键下, -TTL 为会话的 `idle`;`RedisSessionConnector.commandTimeout` 会传给 Perfect-Redis。 +TTL 为会话的 `idle`;`RedisSessionConnector.commandTimeout`(默认 5 秒,设为 nil 则不限)会传给 Perfect-Redis,超时的调用会抛出错误并关闭连接。 **MongoDB** 后端每个会话一个文档,`data` 为子文档,`setup()` 会添加 TTL 索引;`data` 中的空键、`%` 和 NUL 会被转义。 ### 从 Perfect-Session-MongoDB 迁移 From 8c6e5a01f7d077ec2d615165e81a0388fdd7272a Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Wed, 14 Oct 2026 10:06:31 -0600 Subject: [PATCH 33/33] Tests: skip exit tests under ThreadSanitizer; save() renews updated 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 --- .../PerfectSessionCoreTests.swift | 2 +- .../PerfectSessionMongoDBTests.swift | 19 ++++++++++++++++++- .../PerfectSessionMySQLTests.swift | 2 +- .../PerfectSessionRedisTests.swift | 14 +++++++++++++- .../PerfectSessionSQLiteTests.swift | 19 ++++++++++++++++++- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index d58aade..5e82208 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -210,7 +210,7 @@ let underThreadSanitizer: Bool = { /// On Linux, Foundation's UUID() reads /dev/urandom and ignores a failed open(), so with no /// file descriptor left every UUID was 00000000-0000-4000-8000-000000000000: every new /// session got the same token. Runs in a child process, which it starves of descriptors. - /// (Skipped under ThreadSanitizer, whose runtime aborts a process with no descriptors left.) + /// (Skipped under ThreadSanitizer: exit tests abort under it, even one that only calls exit(0).) @Test(.disabled(if: underThreadSanitizer, "ThreadSanitizer needs file descriptors")) func tokensStayRandomWithoutFileDescriptors() async { await #expect(processExitsWith: .success) { diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index d657882..3e9a09e 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -1,6 +1,11 @@ import Testing import Foundation import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionMongoDB import PerfectSessionCore import PerfectMongoDB @@ -456,7 +461,8 @@ struct CapturingLogHandler: LogHandler { /// Failures used to log the full session token as `eventid`; a token in a log is a session /// anyone with log access can take over. Runs in a child process so it can bootstrap logging. -@Test func mongodbLogsDoNotContainTokens() async { +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func mongodbLogsDoNotContainTokens() async { await #expect(processExitsWith: .success) { LoggingSystem.bootstrap { _ in CapturingLogHandler() } @@ -473,3 +479,14 @@ struct CapturingLogHandler: LogHandler { exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) } } + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 4756e67..1a35af6 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -110,7 +110,7 @@ enum MySQLLive { let resumed = try await driver.resume(token: session.token) #expect(resumed.idle == 3_000_000_000) #expect(resumed.created == session.created) - #expect(resumed.updated == session.updated) + #expect(resumed.updated >= session.updated) // save() renews it await driver.clean() _ = try await driver.resume(token: session.token) diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 3da1fdb..60b9f9d 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -352,7 +352,8 @@ struct CapturingLogHandler: LogHandler { /// Failures used to log the full session token as `eventid`; a token in a log is a session /// anyone with log access can take over. Runs in a child process so it can bootstrap logging. -@Test func redisLogsDoNotContainTokens() async { +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func redisLogsDoNotContainTokens() async { await #expect(processExitsWith: .success) { LoggingSystem.bootstrap { _ in CapturingLogHandler() } RedisSessionConnector.host = "127.0.0.1" @@ -409,3 +410,14 @@ final class SilentListener: @unchecked Sendable { #endif } } + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 49f1da0..75ca507 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -1,6 +1,11 @@ import Testing import Foundation import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionSQLite import PerfectSessionCore import PerfectSQLite @@ -299,7 +304,8 @@ struct CapturingLogHandler: LogHandler { /// Failures used to log the full session token as `eventid`; a token in a log is a session /// anyone with log access can take over. Runs in a child process so it can bootstrap logging. -@Test func sqliteLogsDoNotContainTokens() async { +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func sqliteLogsDoNotContainTokens() async { await #expect(processExitsWith: .success) { LoggingSystem.bootstrap { _ in CapturingLogHandler() } SQLiteSessionConnector.databasePath = "/nonexistent-dir/sessions.db" // every call fails and logs @@ -316,3 +322,14 @@ struct CapturingLogHandler: LogHandler { exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) } } + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}()