以前、PythonでCGIのCookieを使ってみました。
Docker + Alpine3.5 + Apache2.4 + Python3.6で、CGIのCookieを使ってみた - メモ的な思考的な
その時は日本語をCookie値へ設定する方法が気になっていたものの、深入りしませんでした。
そこで今回、以下を参考に、日本語をCookie値へ設定する方法について調べたため、メモします。
なお、内容に誤りがあった場合にはご指摘いただけるとありがたいです。
目次
環境
まとめ
長いので最初にまとめます。
- Cookieの仕様はRFC6265にある
- RFC6265によると、Cookie値には日本語が使えない
- ただし、使えない文字の符号化方式については書かれていない
- 調べた範囲では、符号化方式としてURLエンコードを使うケースが多い
- URLエンコードには2種類ある
- Pythonでは以下の関数が使えそう
- RFC3986向き:
urllib.parse.quote()
- HTML5向き:
urllib.parse.quote_plus()
- ただし、両方とも引数がデフォルトのままだと、完全には準拠していない
urllib.parse.quote()
を使って、RFC3986のパーセントエンコードをする場合
- チルダ(
~
)が変換されてしまう
- スラッシュ(
/
)が変換されていない
urllib.parse.quote_plus()
を使って、HTML5のURLエンコードをする場合
- 両方とも引数
safe
を使うと準拠する
- RFC3986向き:
quote(character, safe='~')
- HTML5向き:
quote_plus(character, safe='*')
- とはいえ、Cookie値のURLエンコード用に使うなら、
quote()
やquote_plus()
をデフォルトのまま使っても問題ない
Cookie仕様のRFC6265について
現在のCookieの仕様はRFC6265とのことです。
その 4.1.1. SyntaxにCookie値の仕様が記載されていました。
https://tools.ietf.org/html/rfc6265#section-4.1.1
cookie-pair = cookie-name "=" cookie-value
cookie-name = token
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
; US-ASCII characters excluding CTLs,
; whitespace DQUOTE, comma, semicolon,
; and backslash
cookie-octetを見ると、日本語をそのまま設定するのはダメそうです。
また、cookie-octetで使える文字は書いてあるものの、日本語などの使えない文字に対する符号化方式は書かれていないようです。
そのため、試しに、Pythonのhttp.cookies.SimpleCookie
を使いSet-Cookie
ヘッダを作成してみます。
try_python_SimpleCookie.py
from http.cookies import SimpleCookie
import binascii
ENABLE_OCTET = {
'x21': '!',
'x23': '#',
'x24': '$',
'x25': '%',
'x26': '&',
'x27': "'",
'x28': '(',
'x29': ')',
'x2a': '*',
'x2b': '+',
'x2d': '-',
'x2e': '.',
'x2f': '/',
'x3a': ':',
'x3c': '<',
'x3d': '=',
'x3e': '>',
'x3f': '?',
'x40': '@',
'x5b': '[',
'x5d': ']',
'x5e': '^',
'x5f': '_',
'x60': '`',
'x7b': '{',
'x7c': '|',
'x7d': '}',
'x7e': '~',
}
DISABLE_OCTET = {
'x09': '\t',
'x0a': '\n',
'x0d': '\r',
'x22': '"',
'x2c': ',',
'x3b': ';',
'x5c': '\\',
'whitespace': '\x20'
}
STRINGS = ['a', 'あ', 'a,a', 'a;a', 'a a', 'a あ',]
def print_cookie_header_from_list(characters):
for c in characters:
try:
cookie = SimpleCookie(f'key={c}')
print(f'{c}: {type(cookie)} -> {cookie}')
except Exception as ex_info:
print(f'{c}: {ex_info}')
def print_cookie_header_from_dict(char_dict):
for key, value in char_dict.items():
h = binascii.hexlify(value.encode('utf-8'))
try:
cookie = SimpleCookie(f'{key}={value}')
print(f'{value} ({key}), hex:{h}, {type(cookie)} -> {cookie}')
except Exception as ex_info:
print(f'{key}: {ex_info}')
if __name__ == '__main__':
print_cookie_header_from_dict(ENABLE_OCTET)
print('-'*20)
print_cookie_header_from_dict(DISABLE_OCTET)
print('-'*20)
print_cookie_header_from_list(STRINGS)
実行結果は以下の通りです。
$ python try_cookie_value.py
! (x21), hex:b'21', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x21=!
# (x23), hex:b'23', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x23=#
$ (x24), hex:b'24', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x24=$
% (x25), hex:b'25', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x25=%
& (x26), hex:b'26', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x26=&
' (x27), hex:b'27', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x27='
( (x28), hex:b'28', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x28=(
) (x29), hex:b'29', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x29=)
* (x2a), hex:b'2a', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2a=*
+ (x2b), hex:b'2b', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2b=+
- (x2d), hex:b'2d', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2d=-
. (x2e), hex:b'2e', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2e=.
/ (x2f), hex:b'2f', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2f=/
: (x3a), hex:b'3a', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3a=:
< (x3c), hex:b'3c', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3c=<
= (x3d), hex:b'3d', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3d==
> (x3e), hex:b'3e', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3e=>
? (x3f), hex:b'3f', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3f=?
@ (x40), hex:b'40', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x40=@
[ (x5b), hex:b'5b', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x5b=[
] (x5d), hex:b'5d', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x5d=]
^ (x5e), hex:b'5e', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x5e=^
_ (x5f), hex:b'5f', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x5f=_
` (x60), hex:b'60', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x60=`
{ (x7b), hex:b'7b', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x7b={
| (x7c), hex:b'7c', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x7c=|
} (x7d), hex:b'7d', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x7d=}
~ (x7e), hex:b'7e', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x7e=~
--------------------
(x09), hex:b'09', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x09=
(x0a), hex:b'0a', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x0a=
(x0d), hex:b'0d', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x0d=
" (x22), hex:b'22', <class 'http.cookies.SimpleCookie'> ->
, (x2c), hex:b'2c', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x2c=,
; (x3b), hex:b'3b', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: x3b=
\ (x5c), hex:b'5c', <class 'http.cookies.SimpleCookie'> ->
(whitespace), hex:b'20', <class 'http.cookies.SimpleCookie'> -> Set-Cookie: whitespace=
--------------------
a: <class 'http.cookies.SimpleCookie'> -> Set-Cookie: key=a
あ: <class 'http.cookies.SimpleCookie'> ->
a,a: <class 'http.cookies.SimpleCookie'> -> Set-Cookie: key=a,a
a;a: <class 'http.cookies.SimpleCookie'> ->
a a: <class 'http.cookies.SimpleCookie'> ->
a あ: <class 'http.cookies.SimpleCookie'> -> Set-Cookie: key=a
例外は発生していませんが、http.cookies.SimpleCookie
では、cookie-octet以外の文字を使うとSet-Cookie
ヘッダが正しく作成されないようです。
cookie-octet以外の文字の符号化について
cookie-octet以外の文字の符号化方式を調べてみると、よく使われているのはURLエンコードでした。
URLエンコードについて調べると、2種類の方法がありました。
そこで、両方の符号化方式を調べてみました。
RFC3986に基づくURLエンコード(パーセントエンコード)について
RFC3986にて、使用可能な文字が記載されています。
また、RFC3986とその前のRFC2396における、使用可能な文字の比較などは以下が参考になりました。
RFC3986より、予約されていない文字(=使用可能な文字)は
2.3. Unreserved Characters
Characters that are allowed in a URI but do not have a reserved
purpose are called unreserved. These include uppercase and lowercase
letters, decimal digits, hyphen, period, underscore, and tilde.
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
とのことです。
そのため、
以外がパーセントエンコードされれば良さそうです。
PythonにはURLエンコードする関数として
urllib.parse.quote()
urllib.parse.quote_plus()
の2つがあります。
そこで、引数はデフォルトのまま両方の関数を試してみます。
try_url_encode.py
from urllib.parse import quote, quote_plus
WHITE_SPACE = '\x20'
ALPHABET = 'aA'
DIGIT = '0'
JAPANESE = 'あ'
RFC3986_RESERVED = (
":/?#[]@"
"!$&'()"
"*+,;="
)
RFC3986_UNRESERVED = '-._~'
def main(func):
print(func.__name__)
print('-'*20)
print('[WHITE_SPACE]')
percent_encode(func, WHITE_SPACE)
print('[ALPHABET]')
percent_encode(func, ALPHABET)
print('[DIGIT]')
percent_encode(func, DIGIT)
print('[JAPANESE]')
percent_encode(func, JAPANESE)
print('[RFC3986_RESERVED]')
percent_encode(func, RFC3986_RESERVED)
print('[RFC3986_UNRESERVED]')
percent_encode(func, RFC3986_UNRESERVED)
def percent_encode(func, characters):
for c in characters:
print(f'{c}: {func(c)}')
if __name__ == '__main__':
print('-'*20)
main(quote)
print('-'*20)
main(quote_plus)
urllib.parse.quote()
の実行結果は
--------------------
quote
--------------------
[WHITE_SPACE]
: %20
[ALPHABET]
a: a
A: A
[DIGIT]
0: 0
[JAPANESE]
あ: %E3%81%82
[RFC3986_RESERVED]
:: %3A
/: /
?: %3F
#: %23
[: %5B
]: %5D
@: %40
!: %21
$: %24
&: %26
': %27
(: %28
): %29
*: %2A
+: %2B
,: %2C
;: %3B
=: %3D
[RFC3986_UNRESERVED]
-: -
.: .
_: _
~: %7E
となり、以下がRFC3986に準拠していません。
- チルダ(
~
)が変換されてしまっている
- スラッシュ(
/
)が変換されていない
一方、urllib.parse.quote_plus()
の実行結果は、
--------------------
quote_plus
--------------------
[WHITE_SPACE]
: +
[ALPHABET]
a: a
A: A
[DIGIT]
0: 0
[JAPANESE]
あ: %E3%81%82
[RFC3986_RESERVED]
:: %3A
/: %2F
?: %3F
#: %23
[: %5B
]: %5D
@: %40
!: %21
$: %24
&: %26
': %27
(: %28
): %29
*: %2A
+: %2B
,: %2C
;: %3B
=: %3D
[RFC3986_UNRESERVED]
-: -
.: .
_: _
~: %7E
となり、以下がRFC3986に準拠していません。
- チルダ(
~
)が変換されてしまっている
- 半角スペースが、RFC3986の予約語である
+
になっている
ただ、半角スペースの挙動は、urllib.parse.quote_plus()
の公式ドキュメント通りです。
quote() と似ていますが、クエリ文字列を URL に挿入する時のために HTML フォームの値の空白をプラス記号「+」に置き換えます。オリジナルの文字列に「+」が存在した場合は safe に指定されている場合を除きエスケープされます。safe にデフォルト値は設定されていません。
urllib.parse.quote_plus() | 21.8. urllib.parse — URL を解析して構成要素にする — Python 3.6.1 ドキュメント
以上より、Pythonを使ってRFC3986に準拠するためには、urllib.parse.quote()
の引数safe
にチルダ(~
)を設定すれば良さそうです。
try_url_encode.py
def quote_rfc3986_strictly(character):
return quote(character, safe='~')
if __name__ == '__main__':
...
main(quote_rfc3986_strictly)
結果です。スラッシュとチルダが仕様通りになりました。
--------------------
quote_rfc3986_strictly
--------------------
[WHITE_SPACE]
: %20
[ALPHABET]
a: a
A: A
[DIGIT]
0: 0
[JAPANESE]
あ: %E3%81%82
[RFC3986_RESERVED]
:: %3A
/: %2F
?: %3F
#: %23
[: %5B
]: %5D
@: %40
!: %21
$: %24
&: %26
': %27
(: %28
): %29
*: %2A
+: %2B
,: %2C
;: %3B
=: %3D
[RFC3986_UNRESERVED]
-: -
.: .
_: _
~: ~
HTML5の仕様では
If the byte is 0x20 (U+0020 SPACE if interpreted as ASCII)
eplace the byte with a single 0x2B byte (“+” (U+002B) character if interpreted as ASCII).
If the byte is in the range 0x2A, 0x2D, 0x2E, 0x30 to 0x39, 0x41 to 0x5A, 0x5F, 0x61 to 0x7A
Leave the byte as is.
Otherwise
Let s be a string consisting of a U+0025 PERCENT SIGN character (%) followed by uppercase ASCII hex digits representing the hexadecimal value of the byte in question (zero-padded if necessary).
Encode the string s as US-ASCII, so that it is now a byte string.
Replace the byte in question in the name or value being processed by the bytes in s, preserving their relative order.
https://www.w3.org/TR/html5/forms.html#url-encoded-form-data
とのことです。
そのため、HTML5において、記号は
はそのまま、
は+
へと変換、それ以外はURLエンコードされれば良さそうです。
半角スペースを+
へと変換する必要があるため、Pythonではurllib.parse.quote_plus()
を使います。
そこで、以下を追加して試してみます。
try_url_encode.py
HTML5_ENABLE_OCTET = (
'*'
'-'
'.'
'_'
)
def main(func):
...
print('[HTML5_ENABLE_OCTET]')
percent_encode(func, HTML5_ENABLE_OCTET)
quote_plus()の実行結果を見ると、*
がURLエンコードされてしまっています。
--------------------
quote_plus
--------------------
...
[HTML5_ENABLE_OCTET]
*: %2A
-: -
.: .
_: _
そのため、urllib.parse.quote_plus()
の引数safe
にアスタリスク(*
)を追加すれば良さそうです。
try_url_encode.py
...
def quote_html5_strictly(character):
return quote_plus(character, safe='*')
if __name__ == '__main__':
...
print('-'*20)
main(quote_html5_strictly))
実行してみます。アスタリスク(*
)が変換されなくなりました。
--------------------
quote_html5_strictly
--------------------
[WHITE_SPACE]
: +
[ALPHABET]
a: a
A: A
[DIGIT]
0: 0
[JAPANESE]
あ: %E3%81%82
[RFC3986_RESERVED]
:: %3A
/: %2F
?: %3F
#: %23
[: %5B
]: %5D
@: %40
!: %21
$: %24
&: %26
': %27
(: %28
): %29
*: *
+: %2B
,: %2C
;: %3B
=: %3D
[RFC3986_UNRESERVED]
-: -
.: .
_: _
~: %7E
[HTML5_ENABLE_OCTET]
*: *
-: -
.: .
_: _
Cookieで使えない文字の符号化を確認
RFC3986やHTML5におけるURLエンコードの仕様を確認したところで、Cookieで使えない文字の符号化を確認します。
以下を追加します。
try_url_encode.py
...
COOKIE_DISABLE_OCTET = (
'\t'
'\n'
'\r'
'"'
','
';'
'\\'
'\x20'
)
def main(func):
...
print('[COOKIE_DISABLE_OCTET]')
percent_encode(func, COOKIE_DISABLE_OCTET)
結果を見ると、いずれの方法でも、Cookieで使えない文字が符号化されたり、使える文字へと変換されました。
そのため、Cookie値のURLエンコード用に使うなら、quote()
やquote_plus()
をデフォルトのまま使っても問題なさそうです。
--------------------
quote
--------------------
...
[COOKIE_DISABLE_OCTET]
: %09
: %0A
: %0D
": %22
,: %2C
;: %3B
\: %5C
: %20
--------------------
quote_plus
--------------------
...
[COOKIE_DISABLE_OCTET]
: %09
: %0A
: %0D
": %22
,: %2C
;: %3B
\: %5C
: +
--------------------
quote_rfc3986_strictly
--------------------
...
[COOKIE_DISABLE_OCTET]
: %09
: %0A
: %0D
": %22
,: %2C
;: %3B
\: %5C
: %20
--------------------
quote_html5_strictly
--------------------
...
[COOKIE_DISABLE_OCTET]
: %09
: %0A
: %0D
": %22
,: %2C
;: %3B
\: %5C
: +
その他
CookieのKey名に関しては、こちらに詳しい内容がありました。
技術/HTTP/FormやCookieのkey名に"=“を含めたらどうなるのか? - Glamenv-Septzen.net
GitHubに上げました。e.g._urllib_parse_quote_quote_plus
ディレクトリの中が今回のものです。
thinkAmi-sandbox/python_misc_samples