Skip to content

Fix strict-CSP rendering, secure the evaluator, release 0.1.6#6

Merged
raghubetina merged 7 commits into
mainfrom
agent/support-content-security-policy
Jul 10, 2026
Merged

Fix strict-CSP rendering, secure the evaluator, release 0.1.6#6
raghubetina merged 7 commits into
mainfrom
agent/support-content-security-policy

Conversation

@raghubetina

@raghubetina raghubetina commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Prepares the 0.1.6 patch release that makes SlashConsole safe and usable as the permanent fix for production consoles in strict-CSP apps (Photogram Golden is currently pinned to commit f82d8ab awaiting this release).

Rendering under strict CSP

  • The console page's inline stylesheet carries Rails' per-request nonce, and requesting the nonce during rendering memoizes it into the Rack env so web-console's injected scripts use the same one.
  • The dummy app now reproduces the production topology (move_before ActionDispatch::ShowExceptions, ...ContentSecurityPolicy::Middleware, as in Photogram Golden). With the CSP middleware above web-console, injection happens before the header nonce is minted — this is exactly how 0.1.5 produced a nonempty header nonce with nonce="" scripts and a blank console. Without move_before, the bug is invisible in tests.
  • Regression coverage: integration tests assert a nonempty header nonce, the stylesheet and every injected script carry it, no nonce="" remains, and the policy contains no unsafe-inline/unsafe-eval. A headless-Chrome system test proves the >> prompt mounts, the stylesheet applies, and 6 * 7 evaluates to 42. All of these fail against the 0.1.5 markup.

Security: evaluator endpoints were unauthenticated

web-console serves PUT/POST <mount_point>/repl_sessions/:id directly from WebConsole::Middleware, so the controller's Basic auth never saw them — a session ID was an unauthenticated bearer token for code execution until process restart. Authentication now lives in SlashConsole::BasicAuthMiddleware, inserted directly before WebConsole::Middleware, covering the console page and everything under the evaluator mount point with the same ADMIN_USERNAME/ADMIN_PASSWORD (503 when unset). The path pattern deliberately leaves other /rails/* paths (e.g. ActiveStorage) alone, and the controller filters remain as a second layer for custom mounts. Also removes the duplicate WebConsole::Middleware the engine inserted alongside web-console's own.

Console evaluates at the top level

Input used to evaluate in ConsoleController#index's binding, so ApplicationController resolved to SlashConsole::ApplicationController and app constants could need a leading ::. Each session now gets a fresh TOPLEVEL_BINDING.eval("binding"): self is main, constants resolve like bin/rails console, locals persist within a session but not across sessions.

CI repair

CI had never passed: the test job ran a nonexistent bin/rails test command and the lint job ran omakase RuboCop against Standard-formatted code. Standard is now the linter of record (README/gemspec already said so); rake test and rake test:system exist and CI runs both, plus Chrome for the system test. Ruby is pinned via .ruby-version and the lockfile supports x86_64-linux.

CI has never passed on this repository: the test job ran
"bin/rails db:test:prepare test", but the engine binstub has no test
command, and the lint job ran rubocop-rails-omakase against a codebase
that was formatted with Standard (e48507c), so their spacing rules
disagreed on every literal.

Make Standard the linter of record. README, the gemspec's development
dependency, and the existing formatting all already point at Standard;
omakase was only present because the engine generator scaffolded it.
Remove rubocop-rails-omakase, .rubocop.yml, and the rubocop binstub,
and run "bundle exec standardrb" in CI. Ignore the auto-generated
dummy schema instead of reformatting it.

Give the Rakefile real test tasks: "rake test" (as the README always
claimed) for unit/integration tests and "rake test:system" for browser
tests, with capybara + selenium-webdriver added for the latter. Commit
an empty dummy-app schema so rails/test_help can maintain the test
database, require minitest/mock for stubbing, and point the CI
screenshot artifact at the dummy app's tmp/screenshots.

Pin Ruby 3.4.4 in .ruby-version; CI's setup-ruby now reads it instead
of duplicating the version, and the lockfile (which already carries the
x86_64-linux platform for Linux CI/installs) resolves as-is.
The console captured the binding of ConsoleController#index, so input
was evaluated inside the engine's lexical namespace: ApplicationController
resolved to SlashConsole::ApplicationController, self was the controller
instance, and app-level constants sometimes needed a leading :: to
disambiguate - unlike bin/rails console.

Pass web-console an explicitly created top-level binding instead.
TOPLEVEL_BINDING.eval("binding") yields a binding whose self is main and
whose constant lookup starts at Object, matching bin/rails console, while
still seeing everything Rails has loaded. Because each console session
gets a fresh binding, local variables persist across evaluations within
a session (a REPL essential) but cannot leak between sessions or
pollute TOPLEVEL_BINDING for the rest of the process.
In production, Basic authentication lived in ConsoleController, which
only guards the console page. web-console evaluates code through
PUT/POST <mount_point>/repl_sessions/:id, and those requests are served
directly by WebConsole::Middleware - they never reach the router, so no
controller filter can see them. A session ID (visible to anyone who
loaded the page, or brute-forceable in principle) therefore acted as an
unauthenticated bearer token for arbitrary code execution until the
process restarted.

Move authentication into SlashConsole::BasicAuthMiddleware, inserted
immediately before WebConsole::Middleware. It guards the console page
paths at the standard /rails mount and everything under
WebConsole::Middleware.mount_point, using the same ADMIN_USERNAME /
ADMIN_PASSWORD environment variables (503 when unset, 401 on missing or
wrong credentials). The path pattern deliberately excludes other
/rails/* paths: unmatched requests cascade past the engine to the host
app, which owns e.g. /rails/active_storage/*, and those must keep
working. The controller filters remain as a second layer for apps that
mount the engine at a custom path, now delegating to the middleware's
credential checks so there is one definition of "authorized".

Also stop inserting a second WebConsole::Middleware: web-console's own
railtie already inserts one before ActionDispatch::DebugExceptions, so
the engine's insert_after produced a duplicate pair sandwiching
DebugExceptions. The engine now only inserts the auth middleware,
anchored after web_console.insert_middleware so the target exists.

A middleware stack test pins all of this: exactly one
WebConsole::Middleware, auth directly in front of it, and the protected
path pattern covering every path the engine serves while leaving
cascaded host-app paths alone.
The dummy app's strict nonce CSP did not actually reproduce the blank
console that 0.1.5 shipped to production. Rails places
ContentSecurityPolicy::Middleware below WebConsole::Middleware, so on
the way out the CSP middleware generates and memoizes the request nonce
before web-console injects its scripts, and injection picks up a valid
nonce even without our view ever asking for one.

Production apps (Photogram Golden among them) hoist the CSP middleware
above ShowExceptions so error responses carry the policy too. That
also hoists it above WebConsole::Middleware, inverting the order:
web-console injects first, finds no memoized nonce, and stamps
nonce="" on every script, after which the CSP middleware mints a fresh
nonce for the header. Nonempty header nonce, empty script nonces,
blocked scripts, blank console - the 0.1.5 failure. The view's early
content_security_policy_nonce call is what makes both consumers agree,
because the first caller memoizes the nonce into the Rack env.

Apply the same move_before to the dummy app so the suite covers the
hostile arrangement, and replace the placeholder navigation test with
dedicated coverage: the header nonce is nonempty and appears on the
page stylesheet and on every injected script with no nonce="" left
behind, and the policy stays free of unsafe-inline/unsafe-eval. A
headless-Chrome system test proves the result where it matters - the
>> prompt mounts, the stylesheet actually applies (body margin is
zeroed), and 6 * 7 evaluates to 42. Each of these fails against the
0.1.5 page markup.
Document the strict-CSP rendering fix, the evaluator authentication
gap, the top-level evaluation binding, and the duplicate middleware
removal in the changelog, and refresh the README's description of how
production authentication and the test suite work.
@raghubetina raghubetina changed the title Support strict Content Security Policy Fix strict-CSP rendering, secure the evaluator, release 0.1.6 Jul 10, 2026
@raghubetina
raghubetina merged commit 83c1cc8 into main Jul 10, 2026
2 checks passed
@raghubetina
raghubetina deleted the agent/support-content-security-policy branch July 10, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant