Module: Basecamp::Security

Defined in:
lib/basecamp/security.rb

Overview

Security helpers for URL validation, message truncation, and header redaction. Used across the SDK to enforce HTTPS, prevent SSRF, and protect sensitive data.

Constant Summary collapse

MAX_ERROR_MESSAGE_BYTES =
500
MAX_RESPONSE_BODY_BYTES =

50 MB

50 * 1024 * 1024
MAX_ERROR_BODY_BYTES =

1 MB

1 * 1024 * 1024
LAUNCHPAD_AUTHORIZATION_URL =

The Launchpad authorization endpoint is on a different origin than the configured API base URL, so it is a sanctioned destination for a credentialed cross-origin request. Resource-first discovery may also authorize one specific discovered-and-validated issuer origin (see Http#get_authorization_document, whose issuer comes from internal discovery of the configured base URL, not a caller argument); every other foreign origin is rejected.

"https://launchpad.37signals.com/authorization.json"
SENSITIVE_HEADERS =

Headers that contain sensitive values and should be redacted.

%w[
  authorization
  cookie
  set-cookie
  x-csrf-token
].freeze

Class Method Summary collapse

Class Method Details

.check_body_size!(body, max, label = "Response") ⇒ Object



62
63
64
65
66
67
68
69
70
# File 'lib/basecamp/security.rb', line 62

def self.check_body_size!(body, max, label = "Response")
  return if body.nil?

  if body.bytesize > max
    raise Basecamp::ApiError.new(
      "#{label} body too large (#{body.bytesize} bytes, max #{max})"
    )
  end
end

.localhost?(url) ⇒ Boolean

Returns:

  • (Boolean)


72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/basecamp/security.rb', line 72

def self.localhost?(url)
  uri = URI.parse(url.to_s)
  host = uri.host&.downcase
  return false if host.nil?
  # The carve-out is limited to HTTP(S) so credential guards fail closed
  # on any other scheme (e.g. ws://localhost).
  return false unless %w[http https].include?(uri.scheme&.downcase)

  host == "localhost" ||
    host == "127.0.0.1" ||
    host == "::1" ||
    host == "[::1]" ||
    host.end_with?(".localhost")
rescue URI::InvalidURIError
  false
end

.normalize_host(uri) ⇒ Object



52
53
54
55
56
57
58
59
60
# File 'lib/basecamp/security.rb', line 52

def self.normalize_host(uri)
  host = uri.host&.downcase
  port = uri.port
  return host if port.nil?
  return host if uri.scheme&.downcase == "https" && port == 443
  return host if uri.scheme&.downcase == "http" && port == 80

  "#{host}:#{port}"
end

.redact_headers(headers) ⇒ Hash

Returns a copy of the headers with sensitive values replaced by "[REDACTED]".

This is useful for safely logging HTTP requests and responses without exposing tokens, cookies, or other credentials.

Examples:

headers = { "Authorization" => "Bearer token", "Content-Type" => "application/json" }
safe = Basecamp::Security.redact_headers(headers)
# => { "Authorization" => "[REDACTED]", "Content-Type" => "application/json" }

Parameters:

  • headers (Hash)

    the headers hash (case-insensitive keys)

Returns:

  • (Hash)

    a new hash with sensitive values redacted



187
188
189
190
191
192
193
# File 'lib/basecamp/security.rb', line 187

def self.redact_headers(headers)
  result = {}
  headers.each do |key, value|
    result[key] = SENSITIVE_HEADERS.include?(key.to_s.downcase) ? "[REDACTED]" : value
  end
  result
end

.require_https!(url, label = "URL") ⇒ Object



28
29
30
31
32
33
# File 'lib/basecamp/security.rb', line 28

def self.require_https!(url, label = "URL")
  uri = URI.parse(url.to_s)
  raise UsageError.new("#{label} must use HTTPS: #{url}") unless uri.scheme&.downcase == "https"
rescue URI::InvalidURIError
  raise UsageError.new("Invalid #{label}: #{url}")
end

.require_https_unless_localhost!(url, label = "URL") ⇒ Object



89
90
91
92
93
# File 'lib/basecamp/security.rb', line 89

def self.require_https_unless_localhost!(url, label = "URL")
  return if localhost?(url)

  require_https!(url, label)
end

.require_origin_root!(raw, label = "origin") ⇒ String

Parses a caller- or metadata-supplied origin and enforces the origin-root profile (SPEC.md §16): https (or http on localhost), host present, optional valid numeric port, path empty or exactly "/", and no query, fragment, or userinfo. Parsing uses Ruby's URI (never a regex) so bracketed IPv6 (+http://[::1]:3000+) and ports agree with the host the client dials.

A bad caller origin is a usage error; callers validating an advertised origin rescue UsageError and reclassify.

Parameters:

  • raw (String)

    the origin to validate

  • label (String) (defaults to: "origin")

    a label for error messages

Returns:

  • (String)

    the normalized origin (+scheme://host+, no trailing slash)

Raises:

  • (UsageError)

    on any profile violation or parse failure



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/basecamp/security.rb', line 108

def self.require_origin_root!(raw, label = "origin")
  # Reject C0 controls, space, and backslash up front: URL parsers variously
  # strip tabs/newlines/surrounding spaces or convert backslashes, so a
  # malformed spelling could be cleaned and accepted. None is legitimate here.
  if raw.to_s.match?(/[\x00-\x20\\]/)
    raise UsageError.new("#{label} contains invalid characters: #{raw}")
  end

  uri = URI.parse(raw.to_s)
  scheme = uri.scheme&.downcase

  unless scheme == "https" || (scheme == "http" && localhost?(raw))
    raise UsageError.new("#{label} must use HTTPS (or http on localhost): #{raw}")
  end
  raise UsageError.new("#{label} has no host: #{raw}") if uri.host.nil? || uri.host.empty?

  # The raw authority (between "://" and the first "/?#") backs the presence
  # checks the parsed fields miss: URI reports delimiter-only userinfo
  # ("https://@example.com") as an empty (falsy) string, and normalizes a
  # dangling port ("https://example.com:") to the default-port origin.
  authority = raw.to_s.split("://", 2)[1].to_s.split(%r{[/?#]}, 2)[0].to_s

  # Reject on the PRESENCE of userinfo, not truthiness: an "@" in the authority
  # is always a userinfo delimiter (a host cannot contain one).
  if uri.userinfo || authority.include?("@")
    raise UsageError.new("#{label} must not contain userinfo: #{raw}")
  end
  raise UsageError.new("#{label} must not contain a query or fragment: #{raw}") if uri.query || uri.fragment
  unless uri.path.nil? || uri.path.empty? || uri.path == "/"
    raise UsageError.new("#{label} must be an origin root (no path): #{raw}")
  end

  # A dangling port delimiter ("https://example.com:") silently accepts a
  # malformed authority. IPv6 authorities legitimately end with "]" (e.g.
  # "[::1]"), so only a trailing ":" is a dangling port.
  if authority.end_with?(":")
    raise UsageError.new("#{label} has an invalid port: #{raw}")
  end

  # URI.parse rejects a non-numeric port, but it happily accepts a numeric
  # port outside the valid TCP range (e.g. :0 or :99999). Reject anything
  # outside 1–65535 so a structurally-parseable-but-undialable port can never
  # be treated as a trusted origin.
  if uri.port && !uri.port.between?(1, 65_535)
    raise UsageError.new("#{label} has an out-of-range port: #{raw}")
  end

  # A surviving uri now has a structurally valid, in-range (or default) port.
  # Drop the default port.
  if uri.port && uri.port != uri.default_port
    "#{scheme}://#{uri.host}:#{uri.port}"
  else
    "#{scheme}://#{uri.host}"
  end
rescue URI::InvalidURIError
  raise UsageError.new("Invalid #{label}: not a valid absolute URL: #{raw}")
end

.resolve_url(base, target) ⇒ Object



46
47
48
49
50
# File 'lib/basecamp/security.rb', line 46

def self.resolve_url(base, target)
  URI.join(base, target).to_s
rescue URI::InvalidURIError
  target
end

.same_origin?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


35
36
37
38
39
40
41
42
43
44
# File 'lib/basecamp/security.rb', line 35

def self.same_origin?(a, b)
  ua = URI.parse(a)
  ub = URI.parse(b)
  return false if ua.scheme.nil? || ub.scheme.nil?

  ua.scheme.downcase == ub.scheme.downcase &&
    normalize_host(ua) == normalize_host(ub)
rescue URI::InvalidURIError
  false
end

.truncate(str, max = MAX_ERROR_MESSAGE_BYTES) ⇒ Object



22
23
24
25
26
# File 'lib/basecamp/security.rb', line 22

def self.truncate(str, max = MAX_ERROR_MESSAGE_BYTES)
  return str if str.nil? || str.bytesize <= max

  max <= 3 ? str.byteslice(0, max) : str.byteslice(0, max - 3) + "..."
end