0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/lib/discourse_connect_provider.rb
Osama Sayegh 1ff8a8cc12
DEV: Use constant-time signature comparison for DiscourseConnect HMAC validation (#42424)
## Summary

Replace byte-by-byte string equality comparisons with constant-time
`ActiveSupport::SecurityUtils.secure_compare` when validating HMAC
signatures at both DiscourseConnect boundaries — the consumer-facing
base parser and the provider-secret lookup. The provided signature is
coerced to a string before comparison so missing or malformed values
fail validation cleanly rather than raising, and no behavioral or flow
changes are made to the authentication endpoints.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/1395

Co-authored-by: discourse-patch-triage
<272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-08-07 08:12:32 +03:00

90 lines
2.7 KiB
Ruby
Vendored

# frozen_string_literal: true
class DiscourseConnectProvider < DiscourseConnectBase
class BlankSecret < RuntimeError
end
class BlankReturnUrl < RuntimeError
end
class InvalidParameterValueError < RuntimeError
attr_reader :param
def initialize(param)
@param = param
super("Invalid value for parameter `#{param}`")
end
end
def self.parse(payload, sso_secret = nil, **init_kwargs)
# We extract the return_sso_url parameter early; we need the URL's host
# in order to lookup the correct SSO secret in our site settings.
parsed_payload = Rack::Utils.parse_query(payload)
return_sso_url = lookup_return_sso_url(parsed_payload)
raise ParseError if !return_sso_url
sso_secret ||= lookup_sso_secret(return_sso_url, parsed_payload)
if sso_secret.blank?
begin
host = URI.parse(return_sso_url).host
Rails.logger.warn(
"SSO failed; website #{host} is not in the `discourse_connect_provider_secrets` site settings",
)
rescue StandardError => e
# going for StandardError cause URI::Error may not be enough, eg it parses to something not
# responding to host
Discourse.warn_exception(
e,
message: "SSO failed; invalid or missing return_sso_url in SSO payload",
)
end
raise BlankSecret
end
sso = super(payload, sso_secret, **init_kwargs)
# Do general parameter validation now, after signature-verification has succeeded.
raise InvalidParameterValueError.new("prompt") if (sso.prompt != nil) && (sso.prompt != "none")
sso
end
def self.lookup_return_sso_url(parsed_payload)
decoded = Base64.decode64(parsed_payload["sso"])
decoded_hash = Rack::Utils.parse_query(decoded)
decoded_hash["return_sso_url"]
end
def self.lookup_sso_secret(return_sso_url, parsed_payload)
return nil unless return_sso_url && SiteSetting.enable_discourse_connect_provider
return_url_host = URI.parse(return_sso_url).host
provider_secrets =
SiteSetting
.discourse_connect_provider_secrets
.split("\n")
.map { |row| row.split("|", 2) }
.sort_by { |k, _| k }
.reverse
first_domain_match = nil
pair =
provider_secrets.find do |domain, configured_secret|
if WildcardDomainChecker.check_domain(domain, return_url_host)
first_domain_match ||= configured_secret
ActiveSupport::SecurityUtils.secure_compare(
sign(parsed_payload["sso"], configured_secret),
parsed_payload["sig"].to_s,
)
end
end
# falls back to a secret which will fail to validate in DiscourseConnectBase
# this ensures error flow is correct
pair.present? ? pair[1] : first_domain_match
end
end