mirror of
https://github.com/samsonjs/http-cookie.git
synced 2026-03-25 08:55:53 +00:00
Escaping with the backslash character is not mentioned in the RFC but the backslash character is not allowed here anyway, so just be nice and support it for legacy applications.
474 lines
13 KiB
Ruby
474 lines
13 KiB
Ruby
require 'http/cookie/version'
|
|
require 'time'
|
|
require 'uri'
|
|
require 'domain_name'
|
|
|
|
module HTTP
|
|
autoload :CookieJar, 'http/cookie_jar'
|
|
end
|
|
|
|
# In Ruby < 1.9.3 URI() does not accept an URI object.
|
|
if RUBY_VERSION < "1.9.3"
|
|
begin
|
|
URI(URI(''))
|
|
rescue
|
|
def URI(url)
|
|
url.is_a?(URI) ? url : URI.parse(url)
|
|
end
|
|
end
|
|
end
|
|
|
|
# This class is used to represent an HTTP Cookie.
|
|
class HTTP::Cookie
|
|
# Maximum number of bytes per cookie (RFC 6265 6.1 requires 4096 at least)
|
|
MAX_LENGTH = 4096
|
|
# Maximum number of cookies per domain (RFC 6265 6.1 requires 50 at least)
|
|
MAX_COOKIES_PER_DOMAIN = 50
|
|
# Maximum number of cookies total (RFC 6265 6.1 requires 3000 at least)
|
|
MAX_COOKIES_TOTAL = 3000
|
|
|
|
UNIX_EPOCH = Time.at(0)
|
|
|
|
PERSISTENT_PROPERTIES = %w[
|
|
name value
|
|
domain for_domain path
|
|
secure httponly
|
|
expires created_at accessed_at
|
|
]
|
|
|
|
if String.respond_to?(:try_convert)
|
|
def check_string_type(object)
|
|
String.try_convert(object)
|
|
end
|
|
private :check_string_type
|
|
else
|
|
def check_string_type(object)
|
|
if object.is_a?(String) ||
|
|
(object.respond_to?(:to_str) && (object = object.to_str).is_a?(String))
|
|
object
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
private :check_string_type
|
|
end
|
|
|
|
attr_reader :name, :domain, :path, :origin
|
|
attr_accessor :secure, :httponly, :value, :version
|
|
attr_reader :domain_name, :expires
|
|
attr_accessor :comment, :max_age
|
|
|
|
attr_accessor :session
|
|
|
|
attr_accessor :created_at
|
|
attr_accessor :accessed_at
|
|
|
|
# :call-seq:
|
|
# new(name, value)
|
|
# new(name, value, attr_hash)
|
|
# new(attr_hash)
|
|
#
|
|
# Creates a cookie object. For each key of +attr_hash+, the setter
|
|
# is called if defined. Each key can be either a symbol or a
|
|
# string, downcased or not.
|
|
#
|
|
# This methods accepts any attribute name for which a setter method
|
|
# is defined. Beware, however, any error (typically ArgumentError)
|
|
# a setter method raises will be passed through.
|
|
#
|
|
# e.g.
|
|
# new("uid", "a12345")
|
|
# new("uid", "a12345", :domain => 'example.org',
|
|
# :for_domain => true, :expired => Time.now + 7*86400)
|
|
# new("name" => "uid", "value" => "a12345", "Domain" => 'www.example.org')
|
|
#
|
|
def initialize(*args)
|
|
@version = 0 # Netscape Cookie
|
|
|
|
@origin = @domain = @path =
|
|
@secure = @httponly =
|
|
@expires = @max_age =
|
|
@comment = nil
|
|
|
|
@created_at = @accessed_at = Time.now
|
|
case args.size
|
|
when 2
|
|
self.name, self.value = *args
|
|
@for_domain = false
|
|
return
|
|
when 3
|
|
self.name, self.value, attr_hash = *args
|
|
when 1
|
|
attr_hash = args.first
|
|
else
|
|
raise ArgumentError, "wrong number of arguments (#{args.size} for 1-3)"
|
|
end
|
|
for_domain = false
|
|
origin = nil
|
|
attr_hash.each_pair { |key, val|
|
|
skey = key.to_s.downcase
|
|
if skey.sub!(/\?\z/, '')
|
|
val = val ? true : false
|
|
end
|
|
case skey
|
|
when 'for_domain'
|
|
for_domain = !!val
|
|
when 'origin'
|
|
origin = val
|
|
else
|
|
setter = :"#{skey}="
|
|
send(setter, val) if respond_to?(setter)
|
|
end
|
|
}
|
|
if @name.nil? || @value.nil?
|
|
raise ArgumentError, "at least name and value must be specified"
|
|
end
|
|
@for_domain = for_domain
|
|
if origin
|
|
self.origin = origin
|
|
end
|
|
end
|
|
|
|
# If this flag is true, this cookie will be sent to any host in the
|
|
# +domain+. If it is false, this cookie will be sent only to the
|
|
# host indicated by the +domain+.
|
|
attr_accessor :for_domain
|
|
alias for_domain? for_domain
|
|
|
|
class << self
|
|
include URIFix if defined?(URIFix)
|
|
|
|
# Normalizes a given path. If it is empty, the root path '/' is
|
|
# returned. If a URI object is given, returns a new URI object
|
|
# with the path part normalized.
|
|
def normalize_path(uri)
|
|
# Currently does not replace // to /
|
|
case uri
|
|
when URI
|
|
uri.path.empty? ? uri + '/' : uri
|
|
else
|
|
uri.empty? ? '/' : uri
|
|
end
|
|
end
|
|
|
|
# Parses a Set-Cookie header value +set_cookie+ into an array of
|
|
# Cookie objects. Parts (separated by commas) that are malformed
|
|
# or invalid are silently ignored. For example, a cookie that a
|
|
# given origin is not allowed to issue is not included in the
|
|
# resulted array.
|
|
#
|
|
# If a block is given, each cookie object is passed to the block.
|
|
#
|
|
# Available option keywords are below:
|
|
#
|
|
# * +origin+
|
|
# The cookie's origin URI/URL
|
|
# * +date+
|
|
# The base date used for interpreting Max-Age attribute values
|
|
# instead of the current time
|
|
# * +logger+
|
|
# Logger object useful for debugging
|
|
def parse(set_cookie, options = nil, *_, &block)
|
|
_.empty? && !options.is_a?(String) or
|
|
raise ArgumentError, 'HTTP::Cookie equivalent for Mechanize::Cookie.parse(uri, set_cookie[, log]) is HTTP::Cookie.parse(set_cookie, :origin => uri[, :logger => log]).'
|
|
|
|
if options
|
|
logger = options[:logger]
|
|
origin = options[:origin] and origin = URI(origin)
|
|
date = options[:date]
|
|
end
|
|
date ||= Time.now
|
|
|
|
[].tap { |cookies|
|
|
# The expires attribute may include a comma in the value.
|
|
set_cookie.split(/,(?=[^;,]*=|\s*\z)/).each { |c|
|
|
if c.bytesize > MAX_LENGTH
|
|
logger.warn("Cookie definition too long: #{c}") if logger
|
|
next
|
|
end
|
|
|
|
first_elem, *cookie_elem = c.split(/;+/)
|
|
first_elem.strip!
|
|
key, value = first_elem.split(/\=/, 2)
|
|
# RFC 6265 2.2
|
|
# A cookie-value may be DQUOTE'd.
|
|
case value
|
|
when /\A"(.*)"\z/
|
|
value = $1.gsub(/\\(.)/, "\\1")
|
|
end
|
|
|
|
begin
|
|
cookie = new(key, value.dup)
|
|
rescue
|
|
logger.warn("Couldn't parse key/value: #{first_elem}") if logger
|
|
next
|
|
end
|
|
|
|
cookie_elem.each do |pair|
|
|
pair.strip!
|
|
key, value = pair.split(/=/, 2) #/)
|
|
next unless key
|
|
case value # may be nil
|
|
when /\A"(.*)"\z/
|
|
value = $1.gsub(/\\(.)/, "\\1")
|
|
end
|
|
|
|
case key.downcase
|
|
when 'domain'
|
|
next unless value && !value.empty?
|
|
begin
|
|
cookie.domain = value
|
|
cookie.for_domain = true
|
|
rescue
|
|
logger.warn("Couldn't parse domain: #{value}") if logger
|
|
end
|
|
when 'path'
|
|
next unless value && !value.empty?
|
|
cookie.path = value
|
|
when 'expires'
|
|
next unless value && !value.empty?
|
|
begin
|
|
cookie.expires = Time.parse(value)
|
|
rescue
|
|
logger.warn("Couldn't parse expires: #{value}") if logger
|
|
end
|
|
when 'max-age'
|
|
next unless value && !value.empty?
|
|
begin
|
|
cookie.max_age = Integer(value)
|
|
rescue
|
|
logger.warn("Couldn't parse max age '#{value}'") if logger
|
|
end
|
|
when 'comment'
|
|
next unless value
|
|
cookie.comment = value
|
|
when 'version'
|
|
next unless value
|
|
begin
|
|
cookie.version = Integer(value)
|
|
rescue
|
|
logger.warn("Couldn't parse version '#{value}'") if logger
|
|
cookie.version = nil
|
|
end
|
|
when 'secure'
|
|
cookie.secure = true
|
|
when 'httponly'
|
|
cookie.httponly = true
|
|
end
|
|
end
|
|
|
|
cookie.secure ||= false
|
|
cookie.httponly ||= false
|
|
|
|
# RFC 6265 4.1.2.2
|
|
cookie.expires = date + cookie.max_age if cookie.max_age
|
|
cookie.session = !cookie.expires
|
|
|
|
if origin
|
|
begin
|
|
cookie.origin = origin
|
|
rescue => e
|
|
logger.warn("Invalid cookie for the origin: #{origin} (#{e})") if logger
|
|
next
|
|
end
|
|
end
|
|
|
|
yield cookie if block_given?
|
|
|
|
cookies << cookie
|
|
}
|
|
}
|
|
end
|
|
end
|
|
|
|
def name=(name)
|
|
if name.nil? || name.empty?
|
|
raise ArgumentError, "cookie name cannot be empty"
|
|
elsif name.match(/[\x00-\x1F=\x7F]/)
|
|
raise ArgumentError, "cookie name cannot contain a control character or an equal sign"
|
|
end
|
|
@name = name
|
|
end
|
|
|
|
# Sets the domain attribute. A leading dot in +domain+ implies
|
|
# turning the +for_domain?+ flag on.
|
|
def domain=(domain)
|
|
if DomainName === domain
|
|
@domain_name = domain
|
|
else
|
|
domain = check_string_type(domain) or
|
|
raise TypeError, "#{domain.class} is not a String"
|
|
if domain.start_with?('.')
|
|
@for_domain = true
|
|
domain = domain[1..-1]
|
|
end
|
|
# Do we really need to support this?
|
|
if domain.match(/\A([^:]+):[0-9]+\z/)
|
|
domain = $1
|
|
end
|
|
@domain_name = DomainName.new(domain)
|
|
end
|
|
@domain = @domain_name.hostname
|
|
end
|
|
|
|
# Used to exist in Mechanize::CookieJar. Use #domain=().
|
|
def set_domain(domain)
|
|
raise NoMethodError, 'HTTP::Cookie equivalent for Mechanize::CookieJar#set_domain() is #domain=().'
|
|
end
|
|
|
|
# Sets the path attribute.
|
|
def path=(path)
|
|
@path = HTTP::Cookie.normalize_path(path)
|
|
end
|
|
|
|
# Sets the origin of the cookie. This initializes the `domain` and
|
|
# `path` attribute values if unknown yet.
|
|
def origin=(origin)
|
|
@origin.nil? or
|
|
raise ArgumentError, "origin cannot be changed once it is set"
|
|
origin = URI(origin)
|
|
self.domain ||= origin.host
|
|
self.path ||= (HTTP::Cookie.normalize_path(origin) + './').path
|
|
acceptable_from_uri?(origin) or
|
|
raise ArgumentError, "unacceptable cookie sent from URI #{origin}"
|
|
@origin = origin
|
|
end
|
|
|
|
# Sets the expires attribute. A `Time` object, a string
|
|
# representation of date/time, and `nil` are good values to set.
|
|
def expires=(t)
|
|
case t
|
|
when nil, Time
|
|
@expires = t
|
|
else
|
|
@expires = Time.parse(t)
|
|
end
|
|
end
|
|
|
|
# Tests if this cookie is expired by now, or by a given time.
|
|
def expired?(time = Time.now)
|
|
return false unless @expires
|
|
time > @expires
|
|
end
|
|
|
|
# Expires this cookie by setting the expires attribute value to a
|
|
# past date.
|
|
def expire
|
|
@expires = UNIX_EPOCH
|
|
self
|
|
end
|
|
|
|
alias secure? secure
|
|
alias httponly? httponly
|
|
alias session? session
|
|
|
|
# Tests if it is OK to accept this cookie if it is sent from a given
|
|
# +uri.
|
|
def acceptable_from_uri?(uri)
|
|
uri = URI(uri)
|
|
return false unless URI::HTTP === uri && uri.host
|
|
host = DomainName.new(uri.host)
|
|
|
|
# RFC 6265 5.3
|
|
# When the user agent "receives a cookie":
|
|
return @domain.nil? || host.hostname == @domain unless @for_domain
|
|
|
|
if host.cookie_domain?(@domain_name)
|
|
true
|
|
elsif host.hostname == @domain
|
|
@for_domain = false
|
|
true
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
# Tests if it is OK to send this cookie to a given +uri+, A runtime
|
|
# error is raised if the cookie's domain is unknown.
|
|
def valid_for_uri?(uri)
|
|
if @domain.nil?
|
|
raise "cannot tell if this cookie is valid because the domain is unknown"
|
|
end
|
|
uri = URI(uri)
|
|
return false if secure? && !(URI::HTTPS === uri)
|
|
acceptable_from_uri?(uri) && HTTP::Cookie.normalize_path(uri.path).start_with?(@path)
|
|
end
|
|
|
|
# Returns a string for use in a Cookie header value,
|
|
# i.e. "name=value".
|
|
def cookie_value
|
|
"#{@name}=#{@value}"
|
|
end
|
|
alias to_s cookie_value
|
|
|
|
# Returns a string for use in a Set-Cookie header value. If the
|
|
# cookie does not have an origin set, one must be given from the
|
|
# argument.
|
|
#
|
|
# This method does not check if this cookie will be accepted from
|
|
# the origin.
|
|
def set_cookie_value(origin = nil)
|
|
origin = origin ? URI(origin) : @origin or
|
|
raise "origin must be specified to produce a value for Set-Cookie"
|
|
|
|
string = cookie_value
|
|
if @for_domain || @domain != DomainName.new(origin.host).hostname
|
|
string << "; domain=#{@domain}"
|
|
end
|
|
if (HTTP::Cookie.normalize_path(origin) + './').path != @path
|
|
string << "; path=#{@path}"
|
|
end
|
|
if @expires
|
|
string << "; expires=#{@expires.httpdate}"
|
|
end
|
|
if @comment
|
|
string << "; comment=#{@comment}"
|
|
end
|
|
if @httponly
|
|
string << "; HttpOnly"
|
|
end
|
|
if @secure
|
|
string << "; secure"
|
|
end
|
|
string
|
|
end
|
|
|
|
# Compares the cookie with another. When there are many cookies with
|
|
# the same name for a URL, the value of the smallest must be used.
|
|
def <=>(other)
|
|
# RFC 6265 5.4
|
|
# Precedence: 1. longer path 2. older creation
|
|
(@name <=> other.name).nonzero? ||
|
|
(other.path.length <=> @path.length).nonzero? ||
|
|
(@created_at <=> other.created_at).nonzero? ||
|
|
@value <=> other.value
|
|
end
|
|
include Comparable
|
|
|
|
# YAML serialization helper for Syck.
|
|
def to_yaml_properties
|
|
PERSISTENT_PROPERTIES.map { |name| "@#{name}" }
|
|
end
|
|
|
|
# YAML serialization helper for Psych.
|
|
def encode_with(coder)
|
|
PERSISTENT_PROPERTIES.each { |key|
|
|
coder[key.to_s] = instance_variable_get(:"@#{key}")
|
|
}
|
|
end
|
|
|
|
# YAML deserialization helper for Syck.
|
|
def init_with(coder)
|
|
yaml_initialize(coder.tag, coder.map)
|
|
end
|
|
|
|
# YAML deserialization helper for Psych.
|
|
def yaml_initialize(tag, map)
|
|
map.each { |key, value|
|
|
case key
|
|
when *PERSISTENT_PROPERTIES
|
|
send(:"#{key}=", value)
|
|
end
|
|
}
|
|
end
|
|
end
|