Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions lib/flightdeck/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
require "action_controller/railtie"
require "action_view/railtie"
require "solid_queue"
require "flightdeck/session_fallback"

module Flightdeck
class Engine < ::Rails::Engine
isolate_namespace Flightdeck

# 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
Expand Down
34 changes: 34 additions & 0 deletions lib/flightdeck/session_fallback.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 0 additions & 8 deletions test/integration/dashboard_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

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