Class: ApiKeys::Restrictions
- Inherits:
-
Object
- Object
- ApiKeys::Restrictions
- Defined in:
- lib/api_keys/restrictions.rb
Overview
Value object describing where an API key may be used from: a list of web
origins (hosts, with optional *. subdomain wildcards) and a list of IP
addresses or CIDR ranges.
Restrictions are plain data stored in the restrictions JSON column:
{ "origins" => ["example.com", "*.example.com"],
"ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }
Matching semantics (normative):
- Within a list: OR. Any entry that matches admits the request.
- Across lists: AND. Every list that is present and non-empty must pass.
- Empty (or absent) restrictions mean unrestricted. Presence is the toggle.
- Every failure mode fails closed: a locked list plus an unreadable request context refuses the request.
The object is immutable and has no Active Record dependency. Malformed persisted values are represented explicitly and deny authentication; model validations keep them out during ordinary writes.
Constant Summary collapse
- KINDS =
The restriction kinds this gem understands. Anything else stored in the column is a validation error rather than a silently ignored key.
%i[origins ips].freeze
- KIND_NAMES =
KINDS.map(&:to_s).freeze
- ENTRY_SEPARATOR =
Entries are split on commas, whitespace, and newlines so that a single text field can hold a whole list ("example.com, *.example.com").
/[\s,;]+/- DNS_LABEL =
A bare host, optionally prefixed with a
*.subdomain wildcard.*alone is deliberately invalid: an empty list already means "anywhere". /[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/- ORIGIN_ENTRY_PATTERN =
/\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/
Instance Attribute Summary collapse
-
#extras ⇒ Object
readonly
Returns the value of attribute extras.
-
#ips ⇒ Object
readonly
Returns the value of attribute ips.
-
#origins ⇒ Object
readonly
Returns the value of attribute origins.
Class Method Summary collapse
-
.coerce_list(value) ⇒ Object
private
Coerces one stored list into an array of entries, preserving anything that is not a string so validations can report it instead of the value disappearing silently.
-
.extract_origin_host(request) ⇒ String?
Extracts the host the browser claims the request came from: the Origin header when present, the Referer header otherwise.
-
.host_from_url(value) ⇒ Object
private
Pulls the host out of a full URL, tolerating garbage.
-
.none ⇒ ApiKeys::Restrictions
The shared empty instance: no origins, no IPs, no restrictions at all.
-
.normalize_ips(value) ⇒ Array<String>
Forgiving parser for IP/CIDR input.
-
.normalize_origins(value) ⇒ Array
Forgiving parser for the raw string a dashboard text field submits.
-
.origin_host(entry) ⇒ Object
private
Reduces a single user-supplied entry to a bare lowercase host.
-
.parse_ip(value) ⇒ Object
private
Parses an address or range with stdlib IPAddr.
-
.tokenize(value) ⇒ Object
private
Splits raw input into candidate entries without interpreting them.
-
.valid_ip_entry?(entry) ⇒ Boolean
private
Whether a stored IP entry is a single address or a CIDR range.
-
.valid_origin_entry?(entry) ⇒ Boolean
private
Whether a stored origin entry is shaped like a host or
*.host. -
.wrap(value) ⇒ ApiKeys::Restrictions
Coerces anything into a Restrictions instance.
Instance Method Summary collapse
- #==(other) ⇒ Object (also: #eql?)
-
#allows?(origin_host: nil, ip: nil) ⇒ Boolean
Does this request context satisfy every locked list?.
- #hash ⇒ Object
-
#initialize(origins: [], ips: [], extras: {}, malformed: false) ⇒ Restrictions
constructor
A new instance of Restrictions.
- #inspect ⇒ Object
-
#ip_allowed?(ip) ⇒ Boolean
True when the IP list is empty or one entry contains it.
-
#kinds ⇒ Array<Symbol>
The restriction kinds actually in use.
-
#malformed? ⇒ Boolean
Malformed data can only arrive through validation-bypassing writes or a damaged database.
-
#origin_allowed?(host) ⇒ Boolean
True when the origins list is empty or one entry matches.
-
#restricted? ⇒ Boolean
True when at least one list is locked.
-
#to_h ⇒ Hash
(also: #as_json)
The storage shape: known lists that have entries, plus any unrecognized keys exactly as they were found.
-
#unrestricted? ⇒ Boolean
True when this key may be used from anywhere.
Constructor Details
#initialize(origins: [], ips: [], extras: {}, malformed: false) ⇒ Restrictions
Returns a new instance of Restrictions.
253 254 255 256 257 258 259 260 261 |
# File 'lib/api_keys/restrictions.rb', line 253 def initialize(origins: [], ips: [], extras: {}, malformed: false) @origins = deep_copy(origins, freeze_copy: true) @ips = deep_copy(ips, freeze_copy: true) @extras = deep_copy(extras, freeze_copy: true) @malformed = malformed || @extras.any? || @origins.any? { |entry| !self.class.valid_origin_entry?(entry) } || @ips.any? { |entry| !self.class.valid_ip_entry?(entry) } freeze end |
Instance Attribute Details
#extras ⇒ Object (readonly)
Returns the value of attribute extras.
42 43 44 |
# File 'lib/api_keys/restrictions.rb', line 42 def extras @extras end |
#ips ⇒ Object (readonly)
Returns the value of attribute ips.
42 43 44 |
# File 'lib/api_keys/restrictions.rb', line 42 def ips @ips end |
#origins ⇒ Object (readonly)
Returns the value of attribute origins.
42 43 44 |
# File 'lib/api_keys/restrictions.rb', line 42 def origins @origins end |
Class Method Details
.coerce_list(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Coerces one stored list into an array of entries, preserving anything that is not a string so validations can report it instead of the value disappearing silently.
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/api_keys/restrictions.rb', line 197 def coerce_list(value) entries = case value when nil then [] when String then value.split(ENTRY_SEPARATOR) when Array then value else [value] end entries.filter_map do |entry| next entry unless entry.is_a?(String) trimmed = entry.strip.downcase trimmed unless trimmed.empty? rescue ArgumentError entry end end |
.extract_origin_host(request) ⇒ String?
Extracts the host the browser claims the request came from: the Origin header when present, the Referer header otherwise. Returns nil when neither is present or parseable, which callers must treat as a refusal.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/api_keys/restrictions.rb', line 111 def extract_origin_host(request) headers = request.headers if request.respond_to?(:headers) return nil unless headers.respond_to?(:[]) origin = headers["Origin"] unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?) # Origin has precedence over Referer. A present-but-invalid Origin # (including the browser's opaque `null` origin) must not be rescued # by a friendlier Referer value. return host_from_url(origin) end host_from_url(headers["Referer"]) rescue StandardError # A hostile or exotic request object must never take an endpoint down; # an unreadable origin is simply an origin that matches nothing. nil end |
.host_from_url(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Pulls the host out of a full URL, tolerating garbage.
179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/api_keys/restrictions.rb', line 179 def host_from_url(value) return nil unless value.is_a?(String) trimmed = value.strip return nil if trimmed.empty? host = URI.parse(trimmed).host return nil if host.nil? || host.empty? host.delete_prefix("[").delete_suffix("]").downcase rescue URI::Error, ArgumentError nil end |
.none ⇒ ApiKeys::Restrictions
The shared empty instance: no origins, no IPs, no restrictions at all.
72 73 74 |
# File 'lib/api_keys/restrictions.rb', line 72 def none @none ||= new(origins: [], ips: [], extras: {}).freeze end |
.normalize_ips(value) ⇒ Array<String>
Forgiving parser for IP/CIDR input. String entries are kept verbatim (lowercased) so validation, not the parser, reports malformed ranges. Non-string entries are likewise preserved for validation.
101 102 103 |
# File 'lib/api_keys/restrictions.rb', line 101 def normalize_ips(value) tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq end |
.normalize_origins(value) ⇒ Array
Forgiving parser for the raw string a dashboard text field submits. Accepts full URLs, bare hosts, commas, newlines, and stray whitespace; returns bare lowercase hosts, de-duplicated, order preserved.
normalize_origins("https://Shop.example/, *.app.example\n x")
# => ["shop.example", "*.app.example", "x"]
Non-string entries are preserved so validation can report malformed programmatic input instead of silently erasing a requested policy.
87 88 89 90 91 92 93 |
# File 'lib/api_keys/restrictions.rb', line 87 def normalize_origins(value) tokenize(value).map do |token| next token unless token.is_a?(String) origin_host(token) || token.strip.downcase end.uniq end |
.origin_host(entry) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Reduces a single user-supplied entry to a bare lowercase host. Full URLs give up their host; bare hosts keep everything before the first slash, colon, or question mark. Returns nil when nothing is left.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/api_keys/restrictions.rb', line 152 def origin_host(entry) candidate = entry.to_s.strip return nil if candidate.empty? if candidate.include?("//") host = host_from_url(candidate) return host end if (address = parse_ip(candidate)) && !candidate.include?("/") return address.to_s.downcase end if candidate.start_with?("[") host = host_from_url("http://#{candidate}") return host if host end host = candidate.split(%r{[/?#]}).first.to_s host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000"). host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals. host = host.downcase host.empty? ? nil : host end |
.parse_ip(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Parses an address or range with stdlib IPAddr. A bare address is a /32
(or /128), so IPAddr#include? answers exact matches and range matches
through a single code path.
236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/api_keys/restrictions.rb', line 236 def parse_ip(value) return nil unless value.is_a?(String) trimmed = value.strip return nil if trimmed.empty? address = IPAddr.new(trimmed) address.ipv6? && address.ipv4_mapped? ? address.native : address rescue IPAddr::Error nil end |
.tokenize(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Splits raw input into candidate entries without interpreting them.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/api_keys/restrictions.rb', line 132 def tokenize(value) entries = case value when nil then [] when String then value.split(ENTRY_SEPARATOR) when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] } else [value] end entries.filter_map do |entry| next entry unless entry.is_a?(String) trimmed = entry.strip trimmed unless trimmed.empty? end end |
.valid_ip_entry?(entry) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether a stored IP entry is a single address or a CIDR range.
228 229 230 |
# File 'lib/api_keys/restrictions.rb', line 228 def valid_ip_entry?(entry) parse_ip(entry) ? true : false end |
.valid_origin_entry?(entry) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether a stored origin entry is shaped like a host or *.host.
217 218 219 220 221 222 223 224 |
# File 'lib/api_keys/restrictions.rb', line 217 def valid_origin_entry?(entry) return false unless entry.is_a?(String) return true if !entry.include?("/") && parse_ip(entry) entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN) rescue ArgumentError false end |
.wrap(value) ⇒ ApiKeys::Restrictions
Coerces anything into a Restrictions instance. Never raises.
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/api_keys/restrictions.rb', line 50 def wrap(value) return value if value.is_a?(self) return none if value.nil? return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash) known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) } known = known.to_h { |key, entries| [key.to_s, entries] } new( origins: coerce_list(known["origins"]), ips: coerce_list(known["ips"]), extras: extras.to_h ) rescue StandardError # Stored policy is untrusted input. Preserve the core invariant even if # an exotic object raises while being coerced: malformed never means # unrestricted. new(origins: [], ips: [], extras: {}, malformed: true) end |
Instance Method Details
#==(other) ⇒ Object Also known as: eql?
332 333 334 |
# File 'lib/api_keys/restrictions.rb', line 332 def ==(other) other.is_a?(self.class) && other.to_h == to_h end |
#allows?(origin_host: nil, ip: nil) ⇒ Boolean
Does this request context satisfy every locked list?
301 302 303 |
# File 'lib/api_keys/restrictions.rb', line 301 def allows?(origin_host: nil, ip: nil) !malformed? && origin_allowed?(origin_host) && ip_allowed?(ip) end |
#hash ⇒ Object
337 338 339 |
# File 'lib/api_keys/restrictions.rb', line 337 def hash to_h.hash end |
#inspect ⇒ Object
341 342 343 |
# File 'lib/api_keys/restrictions.rb', line 341 def inspect "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>" end |
#ip_allowed?(ip) ⇒ Boolean
Returns true when the IP list is empty or one entry contains it. A locked list plus an unparseable address refuses: fail closed.
322 323 324 325 326 327 328 329 330 |
# File 'lib/api_keys/restrictions.rb', line 322 def ip_allowed?(ip) return false if malformed? return true if ips.empty? address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s) return false unless address ips.any? { |entry| ip_entry_matches?(entry, address) } end |
#kinds ⇒ Array<Symbol>
Returns The restriction kinds actually in use.
280 281 282 |
# File 'lib/api_keys/restrictions.rb', line 280 def kinds KINDS.select { |kind| public_send(kind).any? } end |
#malformed? ⇒ Boolean
Malformed data can only arrive through validation-bypassing writes or a damaged database. Authentication always denies it.
265 266 267 |
# File 'lib/api_keys/restrictions.rb', line 265 def malformed? @malformed end |
#origin_allowed?(host) ⇒ Boolean
Returns true when the origins list is empty or one entry matches. A locked list plus a nil/blank host refuses: fail closed.
308 309 310 311 312 313 314 315 316 317 |
# File 'lib/api_keys/restrictions.rb', line 308 def origin_allowed?(host) return false if malformed? return true if origins.empty? candidate = host.to_s.strip.downcase return false if candidate.empty? candidate = self.class.parse_ip(candidate)&.to_s || candidate origins.any? { |entry| origin_entry_matches?(entry, candidate) } end |
#restricted? ⇒ Boolean
Returns true when at least one list is locked.
275 276 277 |
# File 'lib/api_keys/restrictions.rb', line 275 def restricted? !unrestricted? end |
#to_h ⇒ Hash Also known as: as_json
The storage shape: known lists that have entries, plus any unrecognized keys exactly as they were found.
287 288 289 290 291 292 |
# File 'lib/api_keys/restrictions.rb', line 287 def to_h hash = {} hash["origins"] = deep_copy(origins) if origins.any? hash["ips"] = deep_copy(ips) if ips.any? hash.merge(deep_copy(extras)) end |
#unrestricted? ⇒ Boolean
Returns true when this key may be used from anywhere.
270 271 272 |
# File 'lib/api_keys/restrictions.rb', line 270 def unrestricted? !malformed? && origins.empty? && ips.empty? end |