Pythonで、日本語をCookie値へ設定する方法を調べてみた

以前、PythonCGICookieを使ってみました。
Docker + Alpine3.5 + Apache2.4 + Python3.6で、CGIのCookieを使ってみた - メモ的な思考的な

その時は日本語をCookie値へ設定する方法が気になっていたものの、深入りしませんでした。

 
そこで今回、以下を参考に、日本語をCookie値へ設定する方法について調べたため、メモします。

 
なお、内容に誤りがあった場合にはご指摘いただけるとありがたいです。

目次

 

環境

 

まとめ

長いので最初にまとめます。

 

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で使える文字は書いてあるものの、日本語などの使えない文字に対する符号化方式は書かれていないようです。

 
そのため、試しに、Pythonhttp.cookies.SimpleCookieを使いSet-Cookieヘッダを作成してみます。

try_python_SimpleCookie.py

from http.cookies import SimpleCookie
import binascii

# RFC6265 4.1.1.Syntax のcookie-octetより
# https://tools.ietf.org/html/rfc6265#section-4.1.1
ENABLE_OCTET = {
    'x21': '!',
    'x23': '#',
    'x24': '$',
    'x25': '%',
    'x26': '&',
    'x27': "'",
    'x28': '(',
    'x29': ')',
    'x2a': '*',
    'x2b': '+',
    'x2d': '-',
    'x2e': '.',
    'x2f': '/',
    # x30 ~ x39は0-9なので省略
    'x3a': ':',
    'x3c': '<',
    'x3d': '=',
    'x3e': '>',
    'x3f': '?',
    'x40': '@',
    # x41 ~ x5aはA-Zなので省略
    'x5b': '[',
    'x5d': ']',
    'x5e': '^',
    'x5f': '_',
    'x60': '`',
    # x61 ~ x7aはa-zなので省略
    'x7b': '{',
    'x7c': '|',
    'x7d': '}',
    'x7e': '~',
}

DISABLE_OCTET = {
    'x09': '\t', # 水平タブ
    'x0a': '\n', # LF(改行)
    'x0d': '\r', # CR
    '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():
        # http://qiita.com/atsaki/items/6120cad2e3c448d774bf
        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以外の文字の符号化方式を調べてみると、よく使われているのは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 = (
    ":/?#[]@"  # gen-delims
    "!$&'()"   # sub-delims 1行目
    "*+,;="    # sub-delims 2行目
)
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__':
    # quote()やquote_plus()の挙動確認
    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の仕様に基づくURLエンコードについて

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 = (
    '*'  # x2a
    '-'  # x2d
    '.'  # x2e
         # x30 ~ x39は0-9なので省略
         # x41 ~ x5aはA-Zなので省略
    '_'  # x5f
         # x61 ~ x7aはa-zなので省略
)

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'    # 水平タブ (x09)
    '\n'    # LF(改行) (x0a)
    '\r'    # CR (x0d)
    '"'     # ダブルクォート(x22)
    ','     # カンマ(x2c)
    ';'     # セミコロン(x3b)
    '\\'    # バックスラッシュ(x5c)
    '\x20'  # whitespace
)

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名について

CookieのKey名に関しては、こちらに詳しい内容がありました。
技術/HTTP/FormやCookieのkey名に"=“を含めたらどうなるのか? - Glamenv-Septzen.net

 

ソースコード

GitHubに上げました。e.g._urllib_parse_quote_quote_plusディレクトリの中が今回のものです。
thinkAmi-sandbox/python_misc_samples