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
- .check_body_size!(body, max, label = "Response") ⇒ Object
- .localhost?(url) ⇒ Boolean
- .normalize_host(uri) ⇒ Object
-
.redact_headers(headers) ⇒ Hash
Returns a copy of the headers with sensitive values replaced by "[REDACTED]".
- .require_https!(url, label = "URL") ⇒ Object
- .require_https_unless_localhost!(url, label = "URL") ⇒ Object
-
.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.
- .resolve_url(base, target) ⇒ Object
- .same_origin?(a, b) ⇒ Boolean
- .truncate(str, max = MAX_ERROR_MESSAGE_BYTES) ⇒ Object
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
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.
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.
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. = 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 || .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 .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
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 |