Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions app/controllers/slash_console/console_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ 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
# 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
prune_console_sessions
console(SlashConsole.console_binding)
render :index
end
Expand All @@ -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
17 changes: 0 additions & 17 deletions app/views/layouts/slash_console/application.html.erb

This file was deleted.

26 changes: 22 additions & 4 deletions lib/slash_console/basic_auth_middleware.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/slash_console/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module SlashConsole
VERSION = "0.1.6"
VERSION = "0.1.7"
end
5 changes: 4 additions & 1 deletion slash_console.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
52 changes: 49 additions & 3 deletions test/integration/authentication_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -116,21 +128,55 @@ 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

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
Expand Down
12 changes: 12 additions & 0 deletions test/integration/console_evaluation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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