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 @@ [![codecov](https://codecov.io/gh/readingbat/readingbat-core/branch/master/graph/badge.svg)](https://codecov.io/gh/readingbat/readingbat-core) [![Kotlin](https://img.shields.io/badge/%20language-Kotlin-red.svg)](https://kotlinlang.org/) [![ktlint](https://img.shields.io/badge/ktlint%20code--style-%E2%9D%A4-FF4081)](https://pinterest.github.io/ktlint/) +[![Docs](https://img.shields.io/badge/docs-readingbat.github.io-blue)](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" }, ] # ---------------------------------------------------------------------------