Class: Obxcura::Cookies
- Inherits:
-
Object
- Object
- Obxcura::Cookies
- 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. # enumerable, scoped to the current URL
page.["session"] # => { "name" => "session", ... }
page..all # every cookie the connection holds
page..set("token", "abc")
page..remove("token")
page..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 storesLax, so this is checked here or nowhere. %w[Strict Lax None].freeze
- WRITABLE =
Ruby option names accepted by #set, mapped to their CDP spelling.
{ 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. %w[name value url domain path secure httpOnly sameSite expires].freeze
Class Method Summary collapse
-
.domain_match?(domain, host) ⇒ Boolean
RFC 6265 §5.1.3, with the leading dot of a domain cookie stripped first.
-
.expired?(cookie, now = Time.now) ⇒ Boolean
CDP reports a session cookie as
expires: -1; anything else is epoch seconds. -
.for_url(cookies, url, now: Time.now) ⇒ Array<Hash>
The cookies from
cookiesthat the browser would attach tourl. -
.match?(cookie, uri, now = Time.now) ⇒ Boolean
Whether the browser would send this cookie there.
- .parse(url) ⇒ Object
-
.path_match?(cookie_path, url_path) ⇒ Boolean
RFC 6265 §5.1.4: equal, or a prefix ending at a
/boundary — so/deepcovers/deep/pagebut not/deeper.
Instance Method Summary collapse
-
#[](name) ⇒ Hash?
The cookie the current URL would send, or nil.
-
#all ⇒ Array<Hash>
Every cookie the connection holds, whatever domain it belongs to and whether or not it is still live.
-
#clear ⇒ void
Drop every cookie the connection holds — not just the ones for this page.
-
#each {|cookie| ... } ⇒ Enumerator
The cookies the browser would send to the page's current URL, including the
HttpOnlyonesdocument.cookiecannot see — usually the point. -
#empty? ⇒ Boolean
Whether the current URL would send any cookie at all.
-
#for_url(url) ⇒ Array<Hash>
The cookies that would go to some other URL instead.
-
#initialize(page) ⇒ Cookies
constructor
A new instance of Cookies.
-
#inspect ⇒ String
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.
-
#remove(name, url: nil, domain: nil, path: nil) ⇒ Boolean
Delete a cookie.
-
#set(name_or_cookie, value = nil, **options) ⇒ Hash
Write a cookie.
-
#size ⇒ Integer
(also: #length)
How many cookies the current URL would send.
Constructor Details
#initialize(page) ⇒ Cookies
Returns a new instance of Cookies.
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.
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.
109 110 111 112 113 114 |
# File 'lib/obxcura/cookies.rb', line 109 def self.expired?(, now = Time.now) expires = ["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.
86 87 88 89 90 91 |
# File 'lib/obxcura/cookies.rb', line 86 def self.for_url(, url, now: Time.now) uri = parse(url) return [] if uri.nil? || uri.hostname.nil? .select { || match?(, uri, now) } end |
.match?(cookie, uri, now = Time.now) ⇒ Boolean
Returns 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?(, uri, now = Time.now) return false if ["secure"] && uri.scheme != "https" return false if expired?(, now) # #hostname, not #host: the latter keeps the brackets on an IPv6 literal # (`[::1]`) while CDP reports the cookie domain bare (`::1`). domain_match?(["domain"].to_s, uri.hostname) && path_match?(["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.
127 128 129 130 131 132 133 |
# File 'lib/obxcura/cookies.rb', line 127 def self.path_match?(, url_path) = "/" if .empty? url_path = "/" if url_path.nil? || url_path.empty? return true if == "/" || url_path == url_path.start_with?(.end_with?("/") ? : "#{}/") end |
Instance Method Details
#[](name) ⇒ Hash?
Returns 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 { || ["name"] == wanted } end |
#all ⇒ Array<Hash>
Every cookie the connection holds, whatever domain it belongs to and whether or not it is still live.
151 152 153 |
# File 'lib/obxcura/cookies.rb', line 151 def all @page.command("Storage.getCookies")["cookies"] end |
#clear ⇒ void
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.
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.
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.
168 169 170 |
# File 'lib/obxcura/cookies.rb', line 168 def for_url(url) self.class.for_url(all, url) end |
#inspect ⇒ String
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.
292 293 294 295 296 |
# File 'lib/obxcura/cookies.rb', line 292 def inspect "#<#{self.class} #{map { || "#{['name']}=#{['value']}" }.join(' ')}>" rescue Error, SystemCallError, IOError => e "#<#{self.class} (unavailable: #{e.})>" end |
#remove(name, url: nil, domain: nil, path: nil) ⇒ Boolean
Delete a cookie.
page..remove("session") # the one this URL would send
page..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.
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 { || ["name"] == wanted } .each do || @page.command("Network.deleteCookies", name: wanted, domain: ["domain"], path: ["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..set("token", "abc")
page..set("token", "abc", path: "/app", http_only: true, expires: Time.now + 3600)
page..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.
217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/obxcura/cookies.rb', line 217 def set(, value = nil, **) params = .is_a?(Hash) ? () : { name: .to_s, value: value.to_s } params = params.merge(translate()) 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 |
#size ⇒ Integer Also known as: length
Returns how many cookies the current URL would send.
180 181 182 |
# File 'lib/obxcura/cookies.rb', line 180 def size count end |