diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e2ccd..f078f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Security + +- Hosts with their own session no longer have it copied into a separate `_flightdeck_session` cookie; Flightdeck now uses the host session directly and only brings its own for API-only hosts. + ## [1.2.0] - 2026-09-04 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d8af656..64151a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Maintain `CHANGELOG.md` for user-visible changes — brief and simple: one line **Rule zero: never monkey patch Solid Queue; never touch the host's connection.** All reads and writes go through `SolidQueue::Record` and the SolidQueue models, so `connects_to` multi-database routing works automatically and semaphore/blocked-execution invariants remain Solid Queue's job. Mutations delegate to Solid Queue's own methods (`FailedExecution#retry`, `Queue#pause`, `Process#prune`, `RecurringTask#enqueue`). No code touches the database at engine load/boot time (host apps must boot with the DB unreachable). -**API-only host support** hinges on engine-local middleware in `lib/flightdeck/engine.rb`: Cookies, CookieStore session (`_flightdeck_session`), and Flash are mounted on the engine only. That is why CSRF, flash, and session work when the host is `config.api_only = true`. The whole test suite runs twice (`FLIGHTDECK_TEST_API_ONLY=1`) to protect this. +**API-only host support** hinges on engine-local middleware in `lib/flightdeck/engine.rb`: `Flightdeck::SessionFallback` (Cookies + CookieStore, `_flightdeck_session`) and Flash are mounted on the engine only. That is why CSRF, flash, and session work when the host is `config.api_only = true`. SessionFallback steps aside when the host already has a session — an unconditional second session store makes Rails copy the host's whole session into the new cookie, and the host's `before_action`s (base_controller_class) must see the host's real session. The whole test suite runs twice (`FLIGHTDECK_TEST_API_ONLY=1`) to protect this. **Auth** (`app/controllers/flightdeck/application_controller.rb`): the controller inherits from `Flightdeck.base_controller_class`. Unconfigured → plain-text 401 explaining the four setup options, in every environment. With `config.base_controller_class` set, the host's `before_action`s are the gate and Flightdeck's HTTP Basic is skipped. `AssetsController` is `ActionController::Metal` and deliberately unauthenticated; asset names are whitelisted against the manifest (`lib/flightdeck/assets.rb`) so no user input ever reaches the filesystem. diff --git a/README.md b/README.md index 645a020..5f2eae0 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,9 @@ Failed jobs are unaffected: Solid Queue never purges them automatically. Works unchanged. Flightdeck carries its own cookies, session (`_flightdeck_session`) and flash as engine-local middleware wrapping only its -own routes — your middleware stack is untouched. The whole suite runs a second -time in API-only mode. +own routes — your middleware stack is untouched. In an app that already has a +session, Flightdeck uses yours and sets no cookie of its own. The whole suite +runs a second time in API-only mode. ## Solid Queue on a separate database diff --git a/lib/flightdeck/engine.rb b/lib/flightdeck/engine.rb index 5458cde..943148b 100644 --- a/lib/flightdeck/engine.rb +++ b/lib/flightdeck/engine.rb @@ -4,6 +4,7 @@ require "action_controller/railtie" require "action_view/railtie" require "solid_queue" +require "flightdeck/session_fallback" module Flightdeck class Engine < ::Rails::Engine @@ -11,9 +12,9 @@ class Engine < ::Rails::Engine # Engine-local middleware: wraps only requests routed into the mount, so # Flightdeck has cookies, a session and flash even when the host app is - # `config.api_only = true` and carries none of them. - middleware.use ActionDispatch::Cookies - middleware.use ActionDispatch::Session::CookieStore, + # `config.api_only = true` and carries none of them. A host that has its + # own session keeps it: SessionFallback steps aside rather than copying it. + middleware.use Flightdeck::SessionFallback, key: "_flightdeck_session", same_site: :lax, httponly: true diff --git a/lib/flightdeck/session_fallback.rb b/lib/flightdeck/session_fallback.rb new file mode 100644 index 0000000..23dbc76 --- /dev/null +++ b/lib/flightdeck/session_fallback.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "action_dispatch" + +module Flightdeck + # Gives the engine a cookie session only when the host has none. + # + # An API-only host carries no cookies or session, so Flightdeck brings its own + # (`_flightdeck_session`) for CSRF and flash. A host that already has a session + # is passed straight through: stacking a second session store on top of it + # would make Rails seed the new session with every key of the host's, then + # write that copy out under Flightdeck's cookie — and the host's own filters + # (base_controller_class) would read the copy instead of the original. + class SessionFallback + def initialize(app, **session_options) + @app = app + @fallback = ActionDispatch::Cookies.new( + ActionDispatch::Session::CookieStore.new(app, **session_options) + ) + end + + def call(env) + host_session?(env) ? @app.call(env) : @fallback.call(env) + end + + private + def host_session?(env) + session = env[Rack::RACK_SESSION] + return false if session.nil? + + !session.respond_to?(:enabled?) || session.enabled? + end + end +end diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb index 3a5810c..0263a29 100644 --- a/test/integration/dashboard_test.rb +++ b/test/integration/dashboard_test.rb @@ -55,14 +55,6 @@ class Flightdeck::DashboardTest < ActionDispatch::IntegrationTest assert_includes response.body, "fd-nav-link on" end - test "the engine has its own session cookie regardless of the host" do - get_authenticated "/flightdeck" - - assert response.headers["Set-Cookie"].to_s.include?("_flightdeck_session"), - "expected the engine-local CookieStore to set _flightdeck_session, got: " \ - "#{response.headers["Set-Cookie"].inspect}" - end - test "flash and session are usable inside the engine even when the host is API-only" do get_authenticated "/flightdeck" diff --git a/test/integration/session_test.rb b/test/integration/session_test.rb new file mode 100644 index 0000000..2dda083 --- /dev/null +++ b/test/integration/session_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +# The engine brings its own cookie session only for hosts that have none. A host +# with a session of its own must keep it: Flightdeck reads and writes that one, +# and never copies it into a second cookie. +class Flightdeck::SessionTest < FlightdeckIntegrationTest + test "an API-only host gets the engine's own session cookie" do + skip "full host: covered by the host-session tests" unless api_only? + + get_fd "/flightdeck" + + assert_includes set_cookie_names, "_flightdeck_session" + end + + test "a host session is used in place, never copied into a Flightdeck cookie" do + skip "API-only host: there is no host session" if api_only? + + sign_into_host("user_id" => 42) + get_fd "/flightdeck" + + assert_response :success + assert_not_includes set_cookie_names, "_flightdeck_session" + assert_nil cookies["_flightdeck_session"] + end + + test "CSRF and flash work through the host session, which keeps its own keys" do + skip "API-only host: there is no host session" if api_only? + + sign_into_host("user_id" => 42) + job = create_failed_job + post_fd "/flightdeck/jobs/#{job.id}/retry" + + assert_response :redirect + assert_match(/retried/i, flash[:notice].to_s) + assert_nil cookies["_flightdeck_session"] + assert_equal 42, host_session["user_id"] + end + + private + def api_only? + Rails.application.config.api_only + end + + def host_session_key + Rails.application.config.session_options.fetch(:key) + end + + def sign_into_host(data) + jar = ActionDispatch::Request.new(Rails.application.env_config.merge("HTTP_HOST" => "www.example.com")).cookie_jar + jar.encrypted[host_session_key] = { value: data.merge("session_id" => SecureRandom.hex(16)) } + cookies[host_session_key] = jar.instance_variable_get(:@set_cookies).fetch(host_session_key)[:value] + end + + def host_session + env = Rails.application.env_config.merge("HTTP_COOKIE" => "#{host_session_key}=#{Rack::Utils.escape(cookies[host_session_key])}") + ActionDispatch::Request.new(env).cookie_jar.encrypted[host_session_key] || {} + end + + def set_cookie_names + Array(response.headers["Set-Cookie"]).flat_map { |header| header.split("\n") }.map { |line| line[/\A[^=]+/] } + end +end