diff --git a/CHANGELOG.md b/CHANGELOG.md
index f439e0c61..7dbe911d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,46 @@ All notable changes to ReadingBat Core are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
-## [3.2.0] - Unreleased
+## [3.2.0] - 2026-06-15
+
+A security-hardening release. A multi-agent security review surfaced 48 confirmed findings (7 high, 19 medium, 22 low); all 48 are addressed here, alongside the build/tooling cleanup below.
+
+### Security
+
+- Signed and encrypted all three session cookies (browser session, auth principal, OAuth return URL) with `SessionTransportTransformerEncrypt` (AES-128 + HMAC-SHA256). Previously the cookies were unsigned plaintext, so the `userId` could be forged for a trivial authentication bypass and full admin takeover
+- Introduced a required `SESSION_SECRET` in production: the server refuses to start without it. It must be shared across all nodes; rotating it invalidates existing cookies. Generate with `openssl rand -hex 32`
+- Enforced class-ownership authorization on teacher class-management actions via `ownsClass()`, closing an IDOR that let any teacher modify any class (including remove-from-class)
+- Eliminated server-side code injection (RCE): Python/Kotlin answer comparison no longer interpolates the user response into an evaluated script — list/array answers are parsed and compared directly
+- Fixed stored XSS in the teacher dashboard: student answers streamed over WebSockets are now HTML-escaped before rendering
+- Fixed JS injection via the student full name in the remove-from-class `confirm()` handler and the other user-prefs `onSubmit` handlers (escaped with `escapeEcmaScript`)
+- Enforced rate limiting with a global per-IP limiter (the RateLimit plugin was installed but never applied)
+- Rotate the browser session cookie on login to prevent session fixation
+- Require a verified email for both Google and GitHub OAuth logins; GitHub no longer creates blank-email user accounts
+- Mask secrets in logs instead of revealing 75% of their characters
+- Require admin access to subscribe to the operational logging WebSocket
+
+### Fixed
+
+- Bounded the answer-check loop by the challenge's invocation count, so an attacker-supplied response count can no longer trigger an `IndexOutOfBoundsException`
+- Startup no longer crashes when an integer environment variable holds a non-numeric value
+- WebSocket reliability: fixed concurrent-modification crashes in the ping/clock pinger loops, replaced the single shared dispatcher that head-of-line-blocked all clients, and collapsed N+1 blocking JDBC calls in WS coroutines; the answer-dashboard flow now drops the oldest message under backpressure instead of suspending the answer-submit handler
+- DSL parser fixes: nested-brace handling in `convertToKotlinScript`, and source-line ordering in `extractJavaInvocations` (invocations now align with computed answers)
+- `extractJavaFunction` reports a named "malformed challenge" error instead of an opaque crash when source has fewer than two `static` declarations
+- Geo cache: short-circuits the DB on a cached hit, no longer serializes all lookups behind a global mutex, no longer caches transient failures permanently, and is now size-bounded (LRU)
+- Fixed a dir-contents cache key mismatch that caused a permanent miss plus a leak
+- Fixed an enrollment-rollback desync, an OAuth-link insert race (now an upsert), and a blocking fetch performed under a `computeIfAbsent` lock
+- Quote-aware parsing of Python list answers so elements containing commas are no longer split and corrupted
+- Background content-load failures are logged (with the failing source) instead of leaving the server permanently "not ready"
+- Coalesced the request-logging path into a single transaction (was 3–4 transactions per request)
### Changed
+- `Challenge.functionInfo()` is now `suspend`: the blocking JSR-223 script eval no longer bridges through `runBlocking` inside request/WS coroutines and runs on `Dispatchers.IO` (depends on the suspend-block `respondWith` in common-utils 2.9.2)
+- Bounded the per-user answer-queue channels with a fixed-size worker pool keyed by user id (was an unbounded map of channels/coroutines)
+- Per-property initialization tracking so the config "not initialized" error reflects the specific property
+- Converted `upsert()` calls to Exposed's native `upsert()` and dropped the local upsert overload
+- Deduplicated repeated `fetchClassTeacherId()` lookups in the class/student authorization paths and removed dead code (the `repo` getter guard, an unused content-root sentinel)
+- Extracted shared WebSocket client JS (origin rewrite + summary `onmessage`) into `PageUtils`, and split `displayStudentProgress` into a data pass and a render pass
- Deduplicated the `/oauth` URL literal: introduced `Endpoints.OAUTH_PREFIX` and derived `OAUTH_LOGIN_*` / `OAUTH_CALLBACK_*` from it; `Intercepts.publicPrefixes` now references `OAUTH_PREFIX` instead of a duplicated string
- Trimmed redundant `"/static/"` entries from `publicPrefixes` and `readinessAllowedPrefixes` (already covered by `"/$STATIC/"`)
- Removed the unused `"/css.css"` entry from `Intercepts.publicPaths`
@@ -26,6 +62,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `scripts/coverage_packages.py` — extracted the inline Python in `make coverage-packages` into a standalone script for readability and reuse
- Makefile private helper targets (`_check-gpg-env`, `_require-version`, `_require-gradle-version`) replacing inline `ifeq` error guards; publish targets now declare them as prerequisites
+### Dependencies
+
+- common-utils → 2.9.2 (suspend-block `respondWith`/`redirectTo`, native Exposed `upsert`, fixed upsert conflict indexes)
+- detekt 2.0.0-alpha.3 → 2.0.0-alpha.4
+- HikariCP 7.0.2 → 7.1.0
+- Kotest 6.1.11 → 6.2.0
+- prometheus-proxy 3.1.1 → 3.2.0
+
## [3.1.8] - 2026-05-04
### Changed
diff --git a/CLAUDE.md b/CLAUDE.md
index 36cf74e52..224c64ed8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -101,8 +101,14 @@ The app uses a two-layer configuration pattern where most settings can come from
### Authentication
- Form-based auth with salted password hashes and session cookies
+- Session cookies are signed and encrypted (`SessionTransportTransformerEncrypt`, AES-128 + HMAC-SHA256) in
+ `ConfigureCookies.kt`. This requires a `SESSION_SECRET` (env var) that is **mandatory in production** — the server
+ will not start without it. It must be identical on every node (so cookies validate across instances), and rotating it
+ invalidates all existing sessions. Generate one with `openssl rand -hex 32`.
+- The browser session is rotated on login to prevent session fixation.
- OAuth (GitHub, Google) via `ConfigureOAuth` — providers are auto-configured when credentials are present in env vars,
not gated on production mode. The `OAuthProvider` enum in `OAuthRoutes.kt` provides type-safe provider identification.
+ OAuth logins require a verified email from the provider.
### Page Generation
@@ -153,9 +159,9 @@ The `readingbat-kotest` module provides `TestSupport` with helpers:
### Key Dependencies
-- **common-utils** 2.8.2 (BOM from `com.github.pambrose`): shared utility library providing core-utils, email-utils,
- exposed-utils, ktor-client/server-utils, script-utils, etc.
-- **prometheus-proxy** 3.1.1: metrics collection
-- **Kover** 0.9.1: code coverage, applied to every subproject and aggregated at the root; CI uploads
+- **common-utils** 2.9.2 (BOM from `com.github.pambrose`): shared utility library providing core-utils, email-utils,
+ exposed-utils, ktor-client/server-utils, script-utils, etc. (`respondWith`/`redirectTo` take a `suspend` block as of 2.9.2)
+- **prometheus-proxy** 3.2.0: metrics collection
+- **Kover** 0.9.8: code coverage, applied to every subproject and aggregated at the root; CI uploads
`build/reports/kover/report.xml` to Codecov via `codecov-action@v5`
- Dependency versions managed in `gradle/libs.versions.toml`
diff --git a/README.md b/README.md
index b9a6d22a7..37bb82b4f 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,13 @@
[](https://codecov.io/gh/readingbat/readingbat-core)
[](https://kotlinlang.org/)
[](https://pinterest.github.io/ktlint/)
+[](https://readingbat.github.io/readingbat-core/)
A Kotlin-based framework for creating interactive programming challenges and educational content, powering the
[ReadingBat](https://readingbat.com) platform for teaching Java, Kotlin, and Python programming concepts.
+📖 **Documentation:**
+
## 🚀 Features
- **Multi-Language Support**: Create challenges for Java, Kotlin, and Python
@@ -17,15 +20,16 @@ A Kotlin-based framework for creating interactive programming challenges and edu
- **Web-Based Platform**: Built on Ktor with real-time WebSocket updates
- **User Management**: Complete authentication system with class/teacher support
- **Progress Tracking**: Detailed analytics and progress monitoring
+- **Security**: Signed + encrypted session cookies, per-IP rate limiting, verified-email OAuth, and class-ownership authorization
- **Scalable Architecture**: Multi-server deployment ready with database persistence
## 🏗️ Architecture
ReadingBat Core is built using modern Kotlin technologies:
-- **Web Framework**: Ktor 3.4.3 with CIO engine
+- **Web Framework**: Ktor 3.5.0 with CIO engine
- **Database**: PostgreSQL with Exposed ORM (`exposed-kotlin-datetime`) and HikariCP connection pooling
-- **Authentication**: OAuth (GitHub, Google) with session management
+- **Authentication**: OAuth (GitHub, Google, verified-email required) with signed + encrypted session cookies
- **Script Execution**: JSR-223 scripting engines for safe code evaluation
- **Build System**: Gradle 9.5 with Kotlin DSL, multi-module structure, and configuration cache enabled
- **Serialization**: kotlinx.serialization for JSON processing
@@ -227,6 +231,7 @@ ReadingBat Core includes comprehensive monitoring:
## 📚 Related Projects
+- **[Documentation site](https://readingbat.github.io/readingbat-core/)**: DSL, configuration, server, testing guides, and release notes
- **[ReadingBat Template](https://github.com/readingbat/readingbat-template)**: Template for creating custom content
## 📄 License
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index ac4c7195b..e9c91c74a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,10 +1,36 @@
# Release Notes
-## v3.2.0 — Unreleased
+## v3.2.0 — 2026-06-15
-Cleanup release: dedupes the `/oauth` URL literal, trims redundant entries from the auth/readiness allowlists, adds a Codecov configuration, centralizes toolchain versions in the version catalog, migrates detekt to the 2.0 alpha line, and tightens the Makefile.
+Security-hardening release. A multi-agent security review surfaced 48 confirmed findings (7 high, 19 medium, 22 low); all 48 are addressed here. The headline items close an authentication bypass, an RCE, two IDOR/authorization gaps, and stored/reflected XSS, plus a batch of WebSocket and caching reliability fixes. The 3.1.9-era build/tooling cleanup ships in the same release.
-### Highlights
+### ⚠️ Upgrade note — `SESSION_SECRET` is now required in production
+
+Session cookies are now signed and encrypted, which means the server **will not start in production without a `SESSION_SECRET`**. Generate one with:
+
+```
+openssl rand -hex 32
+```
+
+Set it via the `SESSION_SECRET` environment variable. It must be the **same value on every node** (so cookies validate across instances), and **rotating it invalidates all existing sessions** (users must log in again).
+
+### Security highlights
+
+- **Signed + encrypted session cookies.** All three session cookies use `SessionTransportTransformerEncrypt` (AES-128 + HMAC-SHA256). The cookies were previously unsigned plaintext, so the `userId` could be hand-edited for a trivial authentication bypass and full admin/sysadmin takeover.
+- **Server-side code injection (RCE) removed.** Python/Kotlin answer checking no longer interpolates the user's response into an evaluated script; list/array answers are parsed and compared directly.
+- **Teacher IDOR closed.** Class-management actions (including remove-from-class) now verify class ownership via `ownsClass()`.
+- **XSS fixes.** Student answers streamed over WebSockets are HTML-escaped before rendering, and user-controlled names in `confirm()` / `onSubmit` handlers are escaped with `escapeEcmaScript`.
+- **OAuth hardening.** Google and GitHub logins require a verified email; GitHub no longer creates blank-email accounts. Login rotates the browser session to prevent fixation.
+- **Rate limiting enforced** with a global per-IP limiter (previously installed but never applied); secrets are masked in logs; the operational logging WebSocket requires admin.
+
+### Reliability highlights
+
+- **WebSocket robustness.** Fixed concurrent-modification crashes in the pinger loops, removed a single shared dispatcher that head-of-line-blocked every client, collapsed N+1 blocking JDBC in WS coroutines, and made the answer-dashboard flow drop-oldest under backpressure instead of suspending the submit handler.
+- **Caching fixes.** Geo cache now short-circuits the DB, drops its global mutex, stops permanently caching failures, and is size-bounded; a dir-contents cache key mismatch (permanent miss + leak) is fixed; per-user answer-queue channels are bounded by a fixed worker pool.
+- **Parser + startup fixes.** Nested-brace Kotlin script conversion, Java invocation ordering, quote-aware Python list parsing, a non-numeric env-var startup crash, and a bounded answer-check loop (no more attacker-triggered `IndexOutOfBoundsException`).
+- **`Challenge.functionInfo()` is now `suspend`** — the blocking script eval no longer bridges through `runBlocking` inside request/WS coroutines and runs on `Dispatchers.IO`.
+
+### Build & tooling highlights
- **`/oauth` URL deduped.** New `Endpoints.OAUTH_PREFIX` constant; `OAUTH_LOGIN_*` and `OAUTH_CALLBACK_*` endpoints derive from it, and `Intercepts.publicPrefixes` references the constant instead of a duplicated literal.
- **Allowlist cleanup.** Removed the redundant `"/static/"` entries from `publicPrefixes` / `readinessAllowedPrefixes` (already covered by `"/$STATIC/"`) and the unused `"/css.css"` entry from `publicPaths`.
@@ -15,6 +41,10 @@ Cleanup release: dedupes the `/oauth` URL literal, trims redundant entries from
- **Kotlin serialization plugin wired explicitly.** `readingbat-core/build.gradle.kts` now applies `libs.plugins.kotlin.serialization` via the catalog alias so the compiler plugin is in effect for the subproject (root keeps the `apply false` declaration).
- **Makefile tightening.** New `make help` target prints a self-documenting index of every target with a `## description` annotation, and a bare `make` invokes it. `make lint` now runs Kotlinter and detekt in a single Gradle invocation instead of double-running detekt via a prerequisite. The inline Python in `make coverage-packages` moves to `scripts/coverage_packages.py`. Inline `ifeq` version guards become explicit prerequisite targets (`_check-gpg-env`, `_require-version`, `_require-gradle-version`), and `$GPG_SIGNING_KEY_ID` is properly quoted in the signing block.
+### Dependencies
+
+common-utils → 2.9.2 · detekt → 2.0.0-alpha.4 · HikariCP → 7.1.0 · Kotest → 6.2.0 · prometheus-proxy → 3.2.0
+
**Full Changelog**: https://github.com/readingbat/readingbat-core/compare/3.1.8...3.2.0
---
diff --git a/llms.txt b/llms.txt
index 38bc07a2c..4274b876d 100644
--- a/llms.txt
+++ b/llms.txt
@@ -6,12 +6,13 @@ ReadingBat Core is a web application built with Ktor that serves programming cha
## Key Technologies
-- Kotlin 2.3.21, Ktor 3.4.3 (CIO engine), Exposed ORM 1.2.0 with kotlinx-datetime, PostgreSQL
+- Kotlin 2.4.0, Ktor 3.5.0 (CIO engine), Exposed ORM 1.3.0 with kotlinx-datetime, PostgreSQL
- Kotlinx.html for server-side HTML generation (no templates)
-- Kotest for testing, Playwright for E2E tests
-- Gradle 9.5.0 multi-module build with version catalog (libs.versions.toml) as the single source of truth for plugin, dependency, and toolchain (`gradle`, `jvm`) versions; parallel project execution and configuration cache enabled
-- Kover 0.9.1 for code coverage, uploaded to Codecov from CI (per-area components defined in `codecov.yml`)
+- Kotest 6.2.0 for testing, Playwright for E2E tests
+- Gradle 9.5.1 multi-module build with version catalog (libs.versions.toml) as the single source of truth for plugin, dependency, and toolchain (`gradle`, `jvm`) versions; parallel project execution and configuration cache enabled
+- Kover 0.9.8 for code coverage, uploaded to Codecov from CI (per-area components defined in `codecov.yml`)
- Detekt 2.0 alpha (`dev.detekt` plugin) for static analysis; Kotlinter for ktlint style
+- common-utils 2.9.2 (shared utility BOM from `com.github.pambrose`)
- Java 17 toolchain
## Project Structure
@@ -60,7 +61,11 @@ Type-safe routing via Ktor `@Resource` annotations in `Locations.kt`: Language -
## Authentication
-OAuth (GitHub, Google) with session management. OAuth providers are auto-configured when credentials are present (not gated on production mode). Provider identification uses the `OAuthProvider` enum in `common/` for type safety.
+OAuth (GitHub, Google) with session management. OAuth providers are auto-configured when credentials are present (not gated on production mode). Provider identification uses the `OAuthProvider` enum in `common/` for type safety. OAuth logins require a verified email; the browser session is rotated on login to prevent fixation.
+
+## Security
+
+Session cookies are signed and encrypted (`SessionTransportTransformerEncrypt`, AES-128 + HMAC-SHA256). This requires a `SESSION_SECRET` (env var), which is **mandatory in production** — the server will not start without it. It must be shared across all nodes, and rotating it invalidates existing sessions. Generate one with `openssl rand -hex 32`. Answer checking parses and compares values directly (no script-engine eval of user input); user-controlled strings are HTML/JS-escaped before rendering; rate limiting is enforced per-IP; secrets are masked in logs. As of 3.2.0 a multi-agent security review of 48 findings (auth, IDOR, RCE, XSS, reliability) was fully addressed.
## Value Types
@@ -82,5 +87,6 @@ Project version, group, and `releaseDate` are defined in `gradle.properties`. Ov
## Links
+- [Documentation](https://readingbat.github.io/readingbat-core/)
- [Source](https://github.com/readingbat/readingbat-core)
- [Template Repository](https://github.com/readingbat/readingbat-template)
diff --git a/website/readingbat-core/docs/configuration/index.md b/website/readingbat-core/docs/configuration/index.md
index d463020a3..9a809fc38 100644
--- a/website/readingbat-core/docs/configuration/index.md
+++ b/website/readingbat-core/docs/configuration/index.md
@@ -134,12 +134,21 @@ Create your secrets file:
```bash
# secrets/secrets.env
+SESSION_SECRET=
DBMS_PASSWORD=my-database-password
GITHUB_OAUTH_CLIENT_SECRET=ghp_xxxxxxxxxxxx
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxx
RESEND_API_KEY=re_xxxxxxxxxxxx
```
+!!! warning "`SESSION_SECRET` is required in production"
+
+ Session cookies are signed and encrypted (AES-128 + HMAC-SHA256), so a
+ `SESSION_SECRET` **must** be set in production — the server will not start
+ without it. Generate one with `openssl rand -hex 32`, use the **same value on
+ every node** (so cookies validate across instances), and note that **rotating
+ it invalidates all existing sessions** (users must log in again).
+
## Property Initialization
Properties are initialized during application startup in `Application.module()`:
diff --git a/website/readingbat-core/docs/release-notes.md b/website/readingbat-core/docs/release-notes.md
new file mode 100644
index 000000000..ba3afcfbf
--- /dev/null
+++ b/website/readingbat-core/docs/release-notes.md
@@ -0,0 +1,41 @@
+---
+icon: lucide/tag
+---
+
+# Release Notes
+
+For the complete, commit-level history see [`CHANGELOG.md`](https://github.com/readingbat/readingbat-core/blob/master/CHANGELOG.md) in the repository.
+
+## v3.2.0 — 2026-06-15
+
+Security-hardening release. A multi-agent security review surfaced 48 confirmed findings (7 high, 19 medium, 22 low); all 48 are addressed here, alongside a batch of WebSocket/caching reliability fixes and the 3.1.9-era build/tooling cleanup.
+
+!!! warning "Upgrade note — `SESSION_SECRET` is now required in production"
+
+ Session cookies are now signed and encrypted, so the server **will not start
+ in production without a `SESSION_SECRET`**. Generate one with
+ `openssl rand -hex 32`, set it via the `SESSION_SECRET` environment variable,
+ use the **same value on every node**, and note that **rotating it invalidates
+ all existing sessions**. See [Configuration › Secrets](configuration/index.md#secrets).
+
+### Security
+
+- **Signed + encrypted session cookies** (`SessionTransportTransformerEncrypt`, AES-128 + HMAC-SHA256). The cookies were previously unsigned plaintext, allowing the `userId` to be forged for a trivial authentication bypass and admin takeover.
+- **Server-side code injection (RCE) removed** — answer checking no longer evaluates the user's response as a script; list/array answers are parsed and compared directly.
+- **Teacher IDOR closed** — class-management actions verify class ownership.
+- **XSS fixes** — student answers over WebSockets are HTML-escaped; user-controlled names in `confirm()`/`onSubmit` handlers are escaped.
+- **OAuth hardening** — Google and GitHub logins require a verified email; login rotates the browser session to prevent fixation.
+- **Rate limiting enforced** per-IP; secrets masked in logs; the operational logging WebSocket requires admin.
+
+### Reliability
+
+- **WebSockets** — fixed pinger concurrent-modification crashes, removed a shared dispatcher that blocked all clients, collapsed N+1 blocking JDBC, and made the answer dashboard drop-oldest under backpressure.
+- **Caching** — geo cache short-circuits the DB, drops its global mutex, stops caching failures permanently, and is size-bounded; a dir-contents cache key mismatch is fixed; per-user answer channels are bounded.
+- **Parsing & startup** — nested-brace Kotlin script conversion, Java invocation ordering, quote-aware Python list parsing, a non-numeric env-var startup crash, and a bounded answer-check loop.
+- **`Challenge.functionInfo()` is now `suspend`** — the blocking script eval runs on `Dispatchers.IO` instead of bridging through `runBlocking` inside request/WS coroutines.
+
+### Dependencies
+
+common-utils → 2.9.2 · Kotlin 2.4.0 · Ktor 3.5.0 · Exposed 1.3.0 · Kotest 6.2.0 · detekt 2.0.0-alpha.4 · HikariCP 7.1.0 · prometheus-proxy 3.2.0
+
+[Full changelog: 3.1.8…3.2.0](https://github.com/readingbat/readingbat-core/compare/3.1.8...3.2.0)
diff --git a/website/readingbat-core/zensical.toml b/website/readingbat-core/zensical.toml
index eb4b2b7ba..786b72339 100644
--- a/website/readingbat-core/zensical.toml
+++ b/website/readingbat-core/zensical.toml
@@ -28,6 +28,7 @@ nav = [
{ "Overview" = "testing/index.md" },
] },
{ "KDocs" = "kdocs.md" },
+ { "Release Notes" = "release-notes.md" },
]
# ---------------------------------------------------------------------------