From 2ca14690ad7c3b9bd7abe4eadff4edd9d11f42c9 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 10 Jul 2026 15:17:56 -0500 Subject: [PATCH 1/4] Require authentication in all deployed environments The engine force-enables web-console in every environment and clears its IP allowlist, but both the auth middleware and the controller filters only engaged when Rails.env.production?. A deployment running any other environment name - staging, preview, demo - therefore served a completely unauthenticated console to the internet. Invert the gate: development and test are the only open environments, and anything else requires ADMIN_USERNAME/ADMIN_PASSWORD (or gets the 503 setup message until they are set), so unrecognized environments now fail safe. The environment predicate lives on BasicAuthMiddleware and the controller's second-layer filters share it. Also stop splatting Rack's decoded Basic credentials into authorized?. Rack 3 rejects credential arrays that aren't exactly two elements in Request#basic?, but Rack 2 (still possible under Rails 7.0, which the gemspec supports) does not, so a header whose decoded value lacks a colon would raise ArgumentError and 500 instead of returning 401. Destructuring pads with nil and authorized? already normalizes with to_s, so malformed headers now fail closed. While here, delete the engine's unused application layout: every render uses layout false, and it referenced a stylesheet that does not ship with the gem. --- .../slash_console/console_controller.rb | 4 +- .../slash_console/application.html.erb | 17 ------ lib/slash_console/basic_auth_middleware.rb | 26 ++++++++-- test/integration/authentication_test.rb | 52 +++++++++++++++++-- 4 files changed, 73 insertions(+), 26 deletions(-) delete mode 100644 app/views/layouts/slash_console/application.html.erb diff --git a/app/controllers/slash_console/console_controller.rb b/app/controllers/slash_console/console_controller.rb index d2f1ea4..1b7ebc0 100644 --- a/app/controllers/slash_console/console_controller.rb +++ b/app/controllers/slash_console/console_controller.rb @@ -9,8 +9,8 @@ class ConsoleController < ApplicationController # standard /rails mount point. These filters remain as a second layer # so the page stays protected if an application mounts the engine at a # custom path. - before_action :ensure_credentials_configured, if: -> { Rails.env.production? } - before_action :authenticate_user, if: -> { Rails.env.production? } + before_action :ensure_credentials_configured, if: -> { BasicAuthMiddleware.authentication_required? } + before_action :authenticate_user, if: -> { BasicAuthMiddleware.authentication_required? } def index console(SlashConsole.console_binding) diff --git a/app/views/layouts/slash_console/application.html.erb b/app/views/layouts/slash_console/application.html.erb deleted file mode 100644 index 72beba1..0000000 --- a/app/views/layouts/slash_console/application.html.erb +++ /dev/null @@ -1,17 +0,0 @@ - - - - Slash console - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= yield :head %> - - <%= stylesheet_link_tag "slash_console/application", media: "all" %> - - - -<%= yield %> - - - diff --git a/lib/slash_console/basic_auth_middleware.rb b/lib/slash_console/basic_auth_middleware.rb index 445feb5..2f474ec 100644 --- a/lib/slash_console/basic_auth_middleware.rb +++ b/lib/slash_console/basic_auth_middleware.rb @@ -1,5 +1,5 @@ module SlashConsole - # Basic authentication for the console in production. + # Basic authentication for the console in deployed environments. # # Authentication must happen in the middleware stack, not in # ConsoleController: web-console's evaluator endpoints (PUT/POST @@ -20,6 +20,14 @@ class BasicAuthMiddleware CONSOLE_PATHS = %r{\A/rails(?:/(?:console)?(?:\.[^/]*)?/?)?\z} class << self + # Development and test are the only environments that get an open + # console. Any deployed environment -- production, staging, preview, + # or anything else -- must present credentials, because the engine + # force-enables web-console everywhere and allows all IPs. + def authentication_required? + !(Rails.env.development? || Rails.env.test?) + end + def credentials_configured? ENV["ADMIN_USERNAME"].present? && ENV["ADMIN_PASSWORD"].present? end @@ -35,14 +43,13 @@ def initialize(app) end def call(env) - return @app.call(env) unless Rails.env.production? && protects?(env[Rack::PATH_INFO].to_s) + return @app.call(env) unless self.class.authentication_required? && protects?(env[Rack::PATH_INFO].to_s) unless self.class.credentials_configured? return [503, {"content-type" => "text/plain"}, [CREDENTIALS_MESSAGE]] end - auth = Rack::Auth::Basic::Request.new(env) - if auth.provided? && auth.basic? && self.class.authorized?(*auth.credentials) + if valid_credentials?(env) @app.call(env) else unauthorized @@ -51,6 +58,17 @@ def call(env) private + # Destructures rather than splatting: Rack 2 (Rails 7.0 apps) does not + # validate credential arity in Request#basic?, so a header whose + # decoded value lacks a colon would otherwise raise instead of 401ing. + def valid_credentials?(env) + auth = Rack::Auth::Basic::Request.new(env) + return false unless auth.provided? && auth.basic? + + username, password = auth.credentials + self.class.authorized?(username, password) + end + def protects?(path) CONSOLE_PATHS.match?(path) || evaluator_path?(path) end diff --git a/test/integration/authentication_test.rb b/test/integration/authentication_test.rb index 1bd0817..d169a27 100644 --- a/test/integration/authentication_test.rb +++ b/test/integration/authentication_test.rb @@ -18,8 +18,12 @@ class AuthenticationTest < ActionDispatch::IntegrationTest ENV["ADMIN_PASSWORD"] = @original_password end + def in_env(name, &block) + Rails.stub(:env, ActiveSupport::EnvironmentInquirer.new(name), &block) + end + def in_production(&block) - Rails.stub(:env, ActiveSupport::EnvironmentInquirer.new("production"), &block) + in_env("production", &block) end def credentials(username, password) @@ -43,6 +47,14 @@ def credentials(username, password) assert_response :unauthorized end + test "a malformed authorization header is refused, not an error" do + in_production do + get "/rails/console", headers: {"Authorization" => "Basic #{["nocolon"].pack("m0")}"} + end + + assert_response :unauthorized + end + test "anonymous evaluator request is refused in production" do in_production do put EVALUATOR_PATH, params: {input: "1 + 1"}, xhr: true @@ -116,13 +128,39 @@ def credentials(username, password) assert_includes response.body, "ADMIN_PASSWORD" end - test "the controller still protects custom mount points in production" do + test "custom deployed environments like staging also require authentication" do + in_env("staging") do + get "/rails/console" + end + + assert_response :unauthorized + + in_env("staging") do + put EVALUATOR_PATH, params: {input: "1 + 1"}, xhr: true + end + + assert_response :unauthorized + + in_env("staging") do + get "/rails/console", headers: credentials(USERNAME, PASSWORD) + end + + assert_response :success + end + + test "the controller still protects custom mount points in deployed environments" do in_production do get "/slash_console/console" end assert_response :unauthorized + in_env("staging") do + get "/slash_console/console" + end + + assert_response :unauthorized + in_production do get "/slash_console/console", headers: credentials(USERNAME, PASSWORD) end @@ -130,7 +168,15 @@ def credentials(username, password) assert_response :success end - test "no authentication is required outside production" do + test "no authentication is required in development" do + in_env("development") do + get "/rails/console" + end + + assert_response :success + end + + test "no authentication is required in the test environment" do get "/rails/console" assert_response :success From f0c9231cdbaea87cb6ff8a5384159fe355e63790 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 10 Jul 2026 15:18:26 -0500 Subject: [PATCH 2/4] Cap stored console sessions at 50 per process WebConsole::Session.inmemory_storage is an append-only hash: every console page load stores a session holding a live binding, and nothing ever removes it, so repeated loads pin memory until the process restarts. Drop the oldest entries once 50 accumulate (Hash preserves insertion order, so shift evicts oldest-first). A tab whose session was evicted gets web-console's normal "session is no longer available" message and recovers on reload. Also remove the CSRF skip_before_action: the console page is GET-only and Rails does not verify authenticity tokens on GET, so the skip (and its defined? guard) did nothing. --- app/controllers/slash_console/console_controller.rb | 13 ++++++++++++- test/integration/console_evaluation_test.rb | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/controllers/slash_console/console_controller.rb b/app/controllers/slash_console/console_controller.rb index 1b7ebc0..604342c 100644 --- a/app/controllers/slash_console/console_controller.rb +++ b/app/controllers/slash_console/console_controller.rb @@ -2,7 +2,12 @@ module SlashConsole class ConsoleController < ApplicationController layout false - skip_before_action :verify_authenticity_token, if: -> { defined?(verify_authenticity_token) } + # web-console stores every console session (and the binding it holds) + # in an in-memory hash that is never evicted, so each page load would + # otherwise pin memory until the process restarts. Old sessions are + # dropped once this many accumulate; an evicted session's tab shows + # web-console's normal "session is no longer available" message. + MAX_STORED_SESSIONS = 50 # BasicAuthMiddleware is the primary guard: it protects both this page # and web-console's evaluator endpoints, but only at the engine's @@ -13,6 +18,7 @@ class ConsoleController < ApplicationController before_action :authenticate_user, if: -> { BasicAuthMiddleware.authentication_required? } def index + prune_console_sessions console(SlashConsole.console_binding) render :index end @@ -30,5 +36,10 @@ def authenticate_user BasicAuthMiddleware.authorized?(username, password) end end + + def prune_console_sessions + storage = WebConsole::Session.inmemory_storage + storage.shift while storage.size >= MAX_STORED_SESSIONS + end end end diff --git a/test/integration/console_evaluation_test.rb b/test/integration/console_evaluation_test.rb index ba9bf2e..ffaeff8 100644 --- a/test/integration/console_evaluation_test.rb +++ b/test/integration/console_evaluation_test.rb @@ -44,4 +44,16 @@ def evaluate(session_id, input) second_session = start_console_session assert_match(/NameError/, evaluate(second_session, "leaky")) end + + test "stored sessions are capped so page loads cannot pin memory forever" do + max = SlashConsole::ConsoleController::MAX_STORED_SESSIONS + oldest_session = start_console_session + + (max + 1).times { start_console_session } + newest_session = start_console_session + + assert_operator WebConsole::Session.inmemory_storage.size, :<=, max + assert_nil WebConsole::Session.find(oldest_session) + assert_equal "=> 42\n", evaluate(newest_session, "6 * 7") + end end From 471a428dd21034ac0aca0997dd16a61d63b60680 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 10 Jul 2026 15:18:39 -0500 Subject: [PATCH 3/4] Tighten gem dependencies, metadata, and CI actions Require web-console >= 4.2.1. The gemspec accepted >= 4.0, but CSP nonces on injected assets only arrived in 4.1.0 (web-console #296) and Rack 3 / Rails 7.1 support in 4.2.1 - an app resolving anything older gets the silent blank-console-under-CSP failure that 0.1.6 fixed. Enable rubygems_mfa_required so pushes and yanks of this gem require a multi-factor-authenticated RubyGems session; a gem that ships a production console is a worthwhile supply-chain target. Bump actions/checkout and actions/upload-artifact to their current majors (v6), clearing the Node 20 deprecation warnings on every run. This supersedes the open dependabot PRs, which predate CI working at all. --- .github/workflows/ci.yml | 6 +++--- slash_console.gemspec | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9a3961..8efb9c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -38,7 +38,7 @@ jobs: run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config google-chrome-stable - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -52,7 +52,7 @@ jobs: run: bundle exec rake app:db:test:prepare test test:system - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: failure() with: name: screenshots diff --git a/slash_console.gemspec b/slash_console.gemspec index 5aa22c9..05d6c22 100644 --- a/slash_console.gemspec +++ b/slash_console.gemspec @@ -10,6 +10,7 @@ Gem::Specification.new do |spec| spec.description = "A Rails engine that provides a web-based console interface at /rails/console, with production authentication support." spec.license = "MIT" + spec.metadata["rubygems_mfa_required"] = "true" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/firstdraft/slash_console" spec.metadata["changelog_uri"] = "https://github.com/firstdraft/slash_console/blob/main/CHANGELOG.md" @@ -23,7 +24,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" spec.add_dependency "rails", ">= 7.0" - spec.add_dependency "web-console", ">= 4.0" + # 4.1.0 added CSP nonces to injected assets; 4.2.1 added Rack 3 and + # Rails 7.1 support. Anything older breaks silently under a strict CSP. + spec.add_dependency "web-console", ">= 4.2.1" spec.add_development_dependency "standard", "~> 1.0" end From 817a1910dcfb6b1e6b9c76ef38ecf8f5160003ef Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 10 Jul 2026 15:18:49 -0500 Subject: [PATCH 4/4] Release 0.1.7 Changelog for the deep-review hardening pass, plus README updates: the authentication section now describes all deployed environments rather than just production, and notes the brute-force caveat on the password prompt, the multi-process session limitation, and that the gem overrides the host app's web_console settings. --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- Gemfile.lock | 4 ++-- README.md | 12 ++++++++---- lib/slash_console/version.rb | 2 +- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1df9d..5376fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7] - 2026-07-10 + +### Security +- Authentication is now required in every environment except `development` + and `test`, instead of only `production`. The engine force-enables + web-console and allows all IPs everywhere, so a `staging`/`preview` + deployment previously served an unauthenticated console. +- Require web-console >= 4.2.1: versions before 4.1.0 do not put CSP + nonces on injected assets (silently breaking under a strict CSP), and + versions before 4.2.1 lack Rack 3 / Rails 7.1 support. +- Enabled `rubygems_mfa_required`, so future gem pushes and yanks require + a multi-factor-authenticated RubyGems session. + +### Fixed +- A malformed Basic Authorization header (credentials without a colon) + now gets a 401 on Rack 2 instead of raising. +- Stored console sessions are capped at 50 per process. web-console's + session store is never evicted otherwise, so each console page load + pinned a binding in memory until process restart. + +### Removed +- Deleted the engine's unused application layout, which referenced a + stylesheet that does not ship with the gem, and a no-op CSRF skip in + the console controller (the console page is GET-only). + ## [0.1.6] - 2026-07-10 ### Security @@ -78,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Development mode with no authentication required - Standard Ruby style guide compliance -[Unreleased]: https://github.com/firstdraft/slash_console/compare/v0.1.6...HEAD +[Unreleased]: https://github.com/firstdraft/slash_console/compare/v0.1.7...HEAD +[0.1.7]: https://github.com/firstdraft/slash_console/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/firstdraft/slash_console/compare/v0.1.0...v0.1.6 [0.1.0]: https://github.com/firstdraft/slash_console/releases/tag/v0.1.0 diff --git a/Gemfile.lock b/Gemfile.lock index e26a22c..646b377 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ PATH remote: . specs: - slash_console (0.1.6) + slash_console (0.1.7) rails (>= 7.0) - web-console (>= 4.0) + web-console (>= 4.2.1) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index 58f169a..b0c584c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Rails engine that provides a web-based console interface at `/rails/console`, - Only use this gem in applications where the security trade-offs are acceptable. Basically, only for toy apps/proofs-of-concept/portfolio projects that contain only sample data. Never use this gem when real user data is at risk. - For serious apps, SSH into the server and run `rails console` at the command-line. This may require upgrading your hosting from free to paid, but you should be doing that anyway if you have real users. -- Make up a strong, unique `ADMIN_PASSWORD` for each app. +- Make up a strong, unique `ADMIN_PASSWORD` for each app. There is no rate limiting on the password prompt, so a short or guessable password can be brute-forced. ## Installation @@ -30,9 +30,9 @@ That's it! Navigate to `/rails/console` in your browser. In development, no authentication is required. Simply visit `/rails/console`. -### Production +### Deployed environments -For production use, authentication is required. Set these environment variables: +In every environment other than `development` and `test` — production, staging, previews, anything — authentication is required. Set these environment variables: ```bash ADMIN_USERNAME="choose_your_own_username" @@ -41,15 +41,19 @@ ADMIN_PASSWORD="choose_your_own_strong_password" Without these environment variables, you'll see an error message explaining what needs to be configured. +If your server runs multiple processes (e.g. Puma workers), console sessions live in the memory of whichever process rendered the page, so evaluating code may intermittently report that your session is no longer available. Run a single process (e.g. `WEB_CONCURRENCY=1`) for a reliable console. + ## How It Works SlashConsole is a lightweight wrapper around [the excellent `web-console` gem](https://github.com/rails/web-console). It: 1. Provides a dedicated route for console access (instead of only on error pages). 2. Renders a full-page console interface, including in apps that enforce a strict nonce-based Content Security Policy. -3. In production, requires basic authentication via a Rack middleware that protects both the console page and web-console's code-evaluation endpoints (`/__web_console/repl_sessions/:id`). +3. In every environment except development and test, requires basic authentication via a Rack middleware that protects both the console page and web-console's code-evaluation endpoints (`/__web_console/repl_sessions/:id`). 4. Evaluates console input at the top level, so constants resolve the same way as in `bin/rails console`. +Note that SlashConsole configures web-console itself: it activates it in all environments and clears its IP allowlist (authentication replaces it). Any `config.web_console` settings in your app will be overridden. + ## Development After checking out the repo, run `bundle install` to install dependencies. diff --git a/lib/slash_console/version.rb b/lib/slash_console/version.rb index 6143585..0a0da0a 100644 --- a/lib/slash_console/version.rb +++ b/lib/slash_console/version.rb @@ -1,3 +1,3 @@ module SlashConsole - VERSION = "0.1.6" + VERSION = "0.1.7" end