Skip to content

Commit 2a42b83

Browse files
committed
Defensive fixes: non-Hash raw_info, filter state/nonce
Two small hardening tweaks from the security review pass: 1. Controller guards against a non-Hash extra.raw_info from the OIDC strategy. A well-behaved omniauth_openid_connect run always gives us a Hash, but a custom or buggy strategy could set it to a String, nil, Array, etc — and the old code went straight to .to_h.transform_keys and crashed the callback action with a 500. Now we collapse anything non-Hash to {} and rebuild sub/email from the top-level auth hash, which is exactly the fallback path the next two lines were already doing for the normal case. Covered by a new request spec. 2. filter_parameters now also masks 'state' and 'nonce'. They aren't secrets in the bearer-token sense, but they are session-bound single-use values and leaking them into logs serves no purpose. Industry default is to treat them as opaque. Updated the security spec to assert on both keys. The agent review also flagged the failure_message log line as a potential PII leak, but closer reading of Devise's OmniauthCallbacksController#failure_message shows it returns a short humanized error code (error_reason || error || error.type) — not the raw OAuth error_description from the IdP. Leaving that log alone.
1 parent a87c359 commit 2a42b83

4 files changed

Lines changed: 44 additions & 11 deletions

File tree

app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ module Devise
1818
# (`:oidc`, from ActiveAdmin::Oidc::Engine::PROVIDER_NAME).
1919
class OmniauthCallbacksController < ::Devise::OmniauthCallbacksController
2020
def oidc
21-
auth = request.env["omniauth.auth"] || {}
22-
info = auth["info"] || {}
23-
extra = auth.dig("extra", "raw_info") || {}
21+
auth = request.env["omniauth.auth"] || {}
22+
info = auth["info"] || {}
23+
# Defensive: an OIDC strategy is supposed to put a Hash at
24+
# extra.raw_info, but a misbehaving/custom strategy could
25+
# set something else (String, nil, Array). We only trust a
26+
# Hash-shaped value; anything else collapses to {} and we
27+
# rebuild `sub`/`email` from the top-level auth hash below.
28+
extra = auth.dig("extra", "raw_info")
29+
extra = {} unless extra.is_a?(Hash)
2430

2531
claims = extra.to_h.transform_keys(&:to_s)
2632
claims["sub"] = auth["uid"] if claims["sub"].blank? && auth["uid"].present?

lib/activeadmin/oidc/engine.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,18 @@ def controllers
5353
end
5454
end
5555

56-
# Prevent OAuth codes and tokens from ever landing in Rails logs.
57-
# If the host app crashes mid-callback Rails will dump the full
58-
# params hash into the log; we merge the known-sensitive keys
59-
# into `filter_parameters` so they come out as [FILTERED]. The
60-
# host app's own filter_parameters are preserved — we only add.
56+
# Prevent OAuth codes, tokens and CSRF/replay guards from ever
57+
# landing in Rails logs. If the host app crashes mid-callback
58+
# Rails will dump the full params hash into the log; we merge
59+
# the known-sensitive keys into `filter_parameters` so they
60+
# come out as [FILTERED]. The host app's own filter_parameters
61+
# are preserved — we only add.
62+
#
63+
# `state` and `nonce` aren't "secret" in the strong sense, but
64+
# they're single-use session-bound values and treating them as
65+
# opaque in logs is the industry default.
6166
initializer "activeadmin_oidc.filter_parameters" do |app|
62-
app.config.filter_parameters |= %i[code id_token access_token refresh_token]
67+
app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce]
6368
end
6469
end
6570
end

spec/requests/omniauth_callback_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ def trigger_callback
127127
end
128128
end
129129

130+
context "misbehaving strategy returns non-Hash raw_info" do
131+
# Defensive: a custom or buggy OIDC strategy could set
132+
# auth.extra.raw_info to a String (or nil, Array, etc) instead of
133+
# the expected Hash of claims. The controller must not crash with
134+
# a NoMethodError/TypeError — it should fall back to building
135+
# claims from auth.uid and auth.info["email"] and proceed normally.
136+
it "falls back to top-level auth fields and still provisions the user" do
137+
OmniAuth.config.mock_auth[:oidc] = OmniAuth::AuthHash.new(
138+
provider: "oidc",
139+
uid: "sub-nohash",
140+
info: { "email" => "nohash@example.com", "name" => "No Hash" },
141+
extra: { "raw_info" => "this-should-be-a-hash-but-isnt" }
142+
)
143+
144+
expect { trigger_callback }.to change(AdminUser, :count).by(1)
145+
146+
user = AdminUser.find_by(provider: "oidc", uid: "sub-nohash")
147+
expect(user).not_to be_nil
148+
expect(user.email).to eq("nohash@example.com")
149+
end
150+
end
151+
130152
context "OmniAuth strategy failure (e.g. invalid_credentials)" do
131153
before do
132154
OmniAuth.config.mock_auth[:oidc] = :invalid_credentials

spec/security_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
# "(?-mix:(?i:code)|(?i:id_token)|(?i:access_token)|...)"
2020
# ), so we stringify the collection and look for each key in it
2121
# rather than asserting on discrete symbol entries.
22-
it "includes the OIDC token/code keys so they never end up in Rails logs" do
22+
it "includes the OIDC token/code/state/nonce keys so they never end up in Rails logs" do
2323
filters_str = Rails.application.config.filter_parameters.map(&:to_s).join(" ")
2424

25-
%w[code id_token access_token refresh_token].each do |key|
25+
%w[code id_token access_token refresh_token state nonce].each do |key|
2626
expect(filters_str).to include(key),
2727
"expected filter_parameters to filter #{key.inspect}, got: #{filters_str}"
2828
end

0 commit comments

Comments
 (0)