Class: Obxcura::Cookies

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/obxcura/cookies.rb

Overview

The browser's cookies, seen from one page: enumerable over the ones that page's URL would send, and writable.

page.cookies                       # enumerable, scoped to the current URL
page.cookies["session"]            # => { "name" => "session", ... }
page.cookies.all                   # every cookie the connection holds
page.cookies.set("token", "abc")
page.cookies.remove("token")
page.cookies.clear

Reached through Page#cookies. Cookies are raw CDP hashes with string keys — the same shape going out as coming in, so one read here can be replayed into another browser.

Every read hits the browser; nothing is cached. Two calls are two round trips.

Why the scoping happens in Ruby

CDP has a parameter for exactly this — Network.getCookies takes urls: — and Obscura ignores it. Measured against 0.2.0: Storage.getCookies, Network.getCookies with or without urls:, and Network.getAllCookies all answer with the same thing, the entire jar of the connection's browser context, whatever URL you ask about and whichever page's session you ask from. Asking for https://example.com/ while parked on localhost still returns the localhost cookies. So Cookies.for_url does the matching here.

The rules are RFC 6265 §5.1.3–5.1.4: domain-match, then path-match, then the Secure flag, then expiry. Ports are not part of cookie scope and are ignored.

Two measured behaviours worth knowing

The jar is the connection's, not the page's. Obscura gives each connection its own browser context, so a second Browser starts clean — but every page on one connection shares one jar, and a page that never navigated anywhere still sees all of it. #all is that jar; the enumerable view is the slice for one URL.

An expired cookie stays in the jar and simply stops being sent. So #all can show you something the browser will never put on the wire, and the scoped view drops it.

One deliberate imprecision

A cookie set with Domain=example.com is a domain cookie — the browser sends it to www.example.com too — while one set without the attribute is host-only. Chrome records the difference by storing the first as .example.com; Obscura stores it verbatim as example.com (measured), so the leading dot is not a reliable marker and there is nothing left to tell the two apart. Domain-matching therefore applies to every entry, which can include a host-only cookie on a subdomain it would not really be sent to. Over-reporting beats dropping the session cookie you came for.

Constant Summary collapse

SAME_SITE =

Values CDP accepts for sameSite. Obscura takes anything and quietly stores Lax, so this is checked here or nowhere.

Returns:

  • (Array<String>)
%w[Strict Lax None].freeze
WRITABLE =

Ruby option names accepted by #set, mapped to their CDP spelling.

Returns:

  • (Hash{Symbol=>Symbol})
{
  url: :url, domain: :domain, path: :path, secure: :secure,
  http_only: :httpOnly, same_site: :sameSite, expires: :expires
}.freeze
REPLAYABLE =

The keys of a cookie hash that #set can send back. A read carries more than a write accepts (size, session, sourcePort, ...), and Obscura takes unknown parameters silently, so the extras are dropped here.

Returns:

  • (Array<String>)
%w[name value url domain path secure httpOnly sameSite expires].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(page) ⇒ Cookies

Returns a new instance of Cookies.

Parameters:

  • page (Obxcura::Page)

    the page whose URL scopes the reads and whose session carries the commands.



143
144
145
# File 'lib/obxcura/cookies.rb', line 143

def initialize(page)
  @page = page
end

Class Method Details

.domain_match?(domain, host) ⇒ Boolean

RFC 6265 §5.1.3, with the leading dot of a domain cookie stripped first.

Returns:

  • (Boolean)


117
118
119
120
121
122
123
# File 'lib/obxcura/cookies.rb', line 117

def self.domain_match?(domain, host)
  domain = domain.delete_prefix(".").downcase
  host = host.downcase
  return false if domain.empty?

  host == domain || host.end_with?(".#{domain}")
end

.expired?(cookie, now = Time.now) ⇒ Boolean

CDP reports a session cookie as expires: -1; anything else is epoch seconds. Obscura keeps expired entries in the jar rather than sweeping them, so this is what stops them being reported as live.

Returns:

  • (Boolean)


109
110
111
112
113
114
# File 'lib/obxcura/cookies.rb', line 109

def self.expired?(cookie, now = Time.now)
  expires = cookie["expires"]
  return false if expires.nil? || expires.negative?

  expires <= now.to_f
end

.for_url(cookies, url, now: Time.now) ⇒ Array<Hash>

The cookies from cookies that the browser would attach to url.

Parameters:

  • cookies (Array<Hash>)

    raw CDP cookie hashes (string keys).

  • url (String, nil)

    the URL to scope to.

  • now (Time) (defaults to: Time.now)

    the moment to judge expiry against.

Returns:

  • (Array<Hash>)

    the matching cookies, in jar order. Empty if url carries no host — about:blank, a data URI, or nothing at all.



86
87
88
89
90
91
# File 'lib/obxcura/cookies.rb', line 86

def self.for_url(cookies, url, now: Time.now)
  uri = parse(url)
  return [] if uri.nil? || uri.hostname.nil?

  cookies.select { |cookie| match?(cookie, uri, now) }
end

.match?(cookie, uri, now = Time.now) ⇒ Boolean

Returns whether the browser would send this cookie there.

Parameters:

  • cookie (Hash)

    a raw CDP cookie hash.

  • uri (URI::Generic)

    the destination, already parsed.

  • now (Time) (defaults to: Time.now)

    the moment to judge expiry against.

Returns:

  • (Boolean)

    whether the browser would send this cookie there.



97
98
99
100
101
102
103
104
# File 'lib/obxcura/cookies.rb', line 97

def self.match?(cookie, uri, now = Time.now)
  return false if cookie["secure"] && uri.scheme != "https"
  return false if expired?(cookie, now)

  # #hostname, not #host: the latter keeps the brackets on an IPv6 literal
  # (`[::1]`) while CDP reports the cookie domain bare (`::1`).
  domain_match?(cookie["domain"].to_s, uri.hostname) && path_match?(cookie["path"].to_s, uri.path)
end

.parse(url) ⇒ Object



135
136
137
138
139
# File 'lib/obxcura/cookies.rb', line 135

def self.parse(url)
  URI.parse(url.to_s)
rescue URI::InvalidURIError
  nil
end

.path_match?(cookie_path, url_path) ⇒ Boolean

RFC 6265 §5.1.4: equal, or a prefix ending at a / boundary — so /deep covers /deep/page but not /deeper.

Returns:

  • (Boolean)


127
128
129
130
131
132
133
# File 'lib/obxcura/cookies.rb', line 127

def self.path_match?(cookie_path, url_path)
  cookie_path = "/" if cookie_path.empty?
  url_path = "/" if url_path.nil? || url_path.empty?
  return true if cookie_path == "/" || url_path == cookie_path

  url_path.start_with?(cookie_path.end_with?("/") ? cookie_path : "#{cookie_path}/")
end

Instance Method Details

#[](name) ⇒ Hash?

Returns the cookie the current URL would send, or nil.

Parameters:

  • name (String, Symbol)

    the cookie name.

Returns:

  • (Hash, nil)

    the cookie the current URL would send, or nil.



174
175
176
177
# File 'lib/obxcura/cookies.rb', line 174

def [](name)
  wanted = name.to_s
  find { |cookie| cookie["name"] == wanted }
end

#allArray<Hash>

Every cookie the connection holds, whatever domain it belongs to and whether or not it is still live.

Returns:

  • (Array<Hash>)

    raw CDP cookie hashes.



151
152
153
# File 'lib/obxcura/cookies.rb', line 151

def all
  @page.command("Storage.getCookies")["cookies"]
end

#clearvoid

This method returns an undefined value.

Drop every cookie the connection holds — not just the ones for this page. Same call as Browser#clear_cookies.



281
282
283
284
# File 'lib/obxcura/cookies.rb', line 281

def clear
  @page.command("Network.clearBrowserCookies")
  nil
end

#each {|cookie| ... } ⇒ Enumerator

The cookies the browser would send to the page's current URL, including the HttpOnly ones document.cookie cannot see — usually the point.

Yield Parameters:

  • cookie (Hash)

    a raw CDP cookie hash.

Returns:

  • (Enumerator)

    if no block is given.



160
161
162
# File 'lib/obxcura/cookies.rb', line 160

def each(&block)
  for_url(@page.current_url).each(&block)
end

#empty?Boolean

Returns whether the current URL would send any cookie at all.

Returns:

  • (Boolean)

    whether the current URL would send any cookie at all.



186
187
188
# File 'lib/obxcura/cookies.rb', line 186

def empty?
  none?
end

#for_url(url) ⇒ Array<Hash>

The cookies that would go to some other URL instead.

Parameters:

  • url (String)

    the URL to scope to.

Returns:

  • (Array<Hash>)

    the matching cookies, in jar order.



168
169
170
# File 'lib/obxcura/cookies.rb', line 168

def for_url(url)
  self.class.for_url(all, url)
end

#inspectString

Shows the cookies the current URL would send, which costs a round trip — this is a live view, so there is nothing local to print. Consoles and debuggers call this implicitly, though, so a page or connection that has gone away degrades into a note rather than raising out of p page.

Returns:

  • (String)


292
293
294
295
296
# File 'lib/obxcura/cookies.rb', line 292

def inspect
  "#<#{self.class} #{map { |cookie| "#{cookie['name']}=#{cookie['value']}" }.join(' ')}>"
rescue Error, SystemCallError, IOError => e
  "#<#{self.class} (unavailable: #{e.message})>"
end

#remove(name, url: nil, domain: nil, path: nil) ⇒ Boolean

Delete a cookie.

page.cookies.remove("session")                 # the one this URL would send
page.cookies.remove("session", path: "/deep")  # a specific one

By default this deletes whichever cookies of that name the current URL would actually send, each at the exact domain and path the jar reports — because Network.deleteCookies compares the path exactly. Measured: a delete aimed at /cookies does not touch a cookie set on /, and one aimed at / does not touch a cookie set on /deep. Naming a domain: or path: yourself skips that lookup and sends what you asked for.

Parameters:

  • name (String, Symbol)

    the cookie name.

  • url (String, nil) (defaults to: nil)

    scope the lookup to this URL instead of the page's current one.

  • domain (String, nil) (defaults to: nil)

    delete at this exact domain.

  • path (String, nil) (defaults to: nil)

    delete at this exact path.

Returns:

  • (Boolean)

    whether a cookie of that name actually went away. false means nothing matched — usually a path that does not line up. Deliberately narrow: the jar is shared by every page on the connection and can change underfoot, so comparing whole snapshots would report somebody else's write as this delete.

Raises:

  • (ArgumentError)

    with neither domain: nor anywhere to match against, exactly as #set refuses.



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/obxcura/cookies.rb', line 253

def remove(name, url: nil, domain: nil, path: nil)
  wanted = name.to_s
  scope = domain ? url : (url || anchor_url)
  before = all
  sent = false

  if domain || path
    params = { name: wanted, url: scope, domain: domain, path: path }.compact
    @page.command("Network.deleteCookies", params)
    sent = true
  else
    self.class.for_url(before, scope)
      .select { |cookie| cookie["name"] == wanted }
      .each do |cookie|
        @page.command("Network.deleteCookies", name: wanted, domain: cookie["domain"], path: cookie["path"])
        sent = true
      end
  end

  return false unless sent

  matching(before, wanted) != matching(all, wanted)
end

#set(name_or_cookie, value = nil, **options) ⇒ Hash

Write a cookie.

page.cookies.set("token", "abc")
page.cookies.set("token", "abc", path: "/app", http_only: true, expires: Time.now + 3600)
page.cookies.set(saved)          # a hash read back from #[] or #all

The browser insists on knowing where the cookie belongs — Network.setCookie refuses with missing required name/domain (or url) — so with neither url: nor domain: given, the page's current URL is used. On about:blank there is nothing to fall back to and this raises before sending anything.

Unknown options raise rather than reaching the browser, which accepts anything and ignores what it does not know. same_site: is checked against SAME_SITE for the same reason: an unrecognised value is stored as Lax without a word of complaint.

Parameters:

  • name_or_cookie (String, Symbol, Hash)

    the cookie name, or a whole cookie hash (string or symbol keys) to replay.

  • value (Object, nil) (defaults to: nil)

    the cookie value, stringified. Omitted when passing a hash.

  • options (Hash)

    url:, domain:, path:, secure:, http_only:, same_site: ("Strict", "Lax", "None"), expires: (a Time or epoch seconds).

Returns:

  • (Hash)

    the parameters sent, useful for logging.

Raises:

  • (ArgumentError)

    on an unknown option, a bad same_site:, or nowhere to attach the cookie to.

  • (Obxcura::Error)

    if the browser reports the write failed.



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/obxcura/cookies.rb', line 217

def set(name_or_cookie, value = nil, **options)
  params = name_or_cookie.is_a?(Hash) ? from_cookie(name_or_cookie) : { name: name_or_cookie.to_s, value: value.to_s }
  params = params.merge(translate(options))
  params = params.merge(url: anchor_url) unless params[:url] || params[:domain]
  validate!(params)

  result = @page.command("Network.setCookie", params)
  raise Error, "the browser refused to set cookie #{params[:name].inspect}" if result["success"] == false

  params
end

#sizeInteger Also known as: length

Returns how many cookies the current URL would send.

Returns:

  • (Integer)

    how many cookies the current URL would send.



180
181
182
# File 'lib/obxcura/cookies.rb', line 180

def size
  count
end