Perl/CGI研究室 'PERL-LABO'

Perl/CGI研究室 'PERL-LABO' TOPへ
戻る(History.Back)

クッキー処理汎用ライブラリの改良

研究内容

ここまでクッキーの研究をしてきましたが、どうやら不完全だったようです…。 修正するとともに、パスの設定などまだ研究していないクッキーの処理方法を研究しましょう。

詳細

クッキーの正式な書式

ここまでで学んだクッキーを設定する際のHTTPヘッダの書式は、次のようなものでした。

Set-cookie: クッキー; expires=有効期限;

これは間違いではありませんが、完全ではありませんでした。 正式な書式は、次のようなものです。

Set-Cookie: 名前=値; expires=有効期限; path=パス; domain=ドメイン名;

先頭は、単なる文字列でなく、名前=値 という形にするという点が今まで考慮していなかった点です。 また、新しく path、domain というものが出てきました。これらをクッキーの属性といいます。 そして、この Set-Cookie は何行でも好きなだけ出力することができるということです。

クッキーの正式な書式の詳細

名前=値 のところは、保存したいデータですね。

expires は、有効期限でした。これは研究しましたね。 省略もできますが、省略するとクッキーがすぐに削除されるんでした。

pathは、そのクッキーを受け取るCGIプログラムのパスです。 このパスを含むCGIプログラムでこのクッキーを受け取ることができるようになります。 例えば、次のように設定したとしましょう。

Set-Cookie: 名前=値; expires=有効期限; path=/hogehoge/; domain=ドメイン名;

すると、このクッキーを出力したCGIプログラムがどこに置かれているのかとは無関係に、 /hogehoge/ 以下のCGIプログラムでこのクッキーを受け取ることができるようになります。 path は省略可能で、省略した場合、このクッキーを出力したCGIプログラムのパスが設定されます。 そのため、以前研究したように、 「クッキーを出力したディレクトリおよびその下のCGIプログラムがそのクッキーを受け取る」 ということになるわけです。

domain は、そのクッキーを受け取るドメイン名。 path は自分以外のCGIプログラムにクッキーを渡すための機能ですが、domain は 他のサイトのCGIプログラムにクッキーを渡すという機能ですね。 たとえば

Set-Cookie: 名前=値; expires=有効期限; path=パス; domain=www.yahoo.co.jp;

とすると、このクッキーをYahoo!Japanが受け取るということだと思います。 こんなことしたら、Yahoo!も困っちゃいますね (^^; でもこの機能、 本当にこれでYahoo!にデータを送れるんでしょうか? というのは、他のサイトのクッキーを上書きできちゃうっていうのは セキュリティ上危険ですよね? なので、実際は上のようにdomainを指定しても、ダメだと思いますが、 どうなんでしょう。 でも、もともと、この機能が必要になることは一般のサイトでは無いと思うので、 今回は詳しくは研究しないことにします。 これも省略可能で、省略した場合は自動的に自分のドメインが入ります。

クッキーの中に書き込めない文字

さて、ここまでで分かることとして、クッキーの中には書き込めない文字があるということです。 というのは、クッキーはこのように 名前=値; という複数の属性で構成されていますので、 名前や、値に = ; といった文字が含まれていると、それがクッキー側で処理されて おかしなことになってしまいます。 ですから、名前、値に = ; が含まれている場合は、これらをエスケープといいますか、 %〜 の形に変換するなどしないといけないわけです。 今までに作ったクッキー処理ライブラリは、この処理をしていませんでしたね。 修正が必要です。具体的な修正方法はのちほど。

複数のクッキーを書き込む場合

複数のクッキーを書き込む場合、正式には次のように Set-cookie を複数行出力します。

Set-Cookie: 名前=値; expires=有効期限;
Set-Cookie: 名前=値; expires=有効期限;

path と domain は省略可能ですので、省略しました。 このように、名前=値 を1行に1個ずつ出力することで、 それぞれに有効期限を設定することができるっていうことがわかりますね。 あるいは、ここでは省略しましたが、path を指定すれば、一部のクッキーだけ 別のCGIプログラムに渡すということもできます。 なるほど!時には、このように個別に設定できるというのは便利でしょうね。

複数のクッキーを読み込んだ場合

複数のクッキーの書き込み方法は分かりましたが、 複数のクッキーを読み込む場合にどうなるのかっていうことを 考えて見ましょう。これは、テストしてみるのが一番早そうです。 複数クッキーの書き込みCGIプログラムと、読み込みCGIプログラムを作成しました。

writecookie.cgi
#!/usr/bin/perl

print "Set-cookie: Var1=Val1;\n";
print "Set-cookie: Var2=Val2;\n";
print "Content-type: text/html\n";
print "\n";
print "書き込みました。";
writecookie2.cgi
#!/usr/bin/perl

print "Set-cookie: Var2=Val300;\n";
print "Set-cookie: Var3=Val4;\n";
print "Content-type: text/html\n";
print "\n";
print "書き込みました。";
readcookie.cgi
#!/usr/bin/perl

print "Content-type: text/html\n";
print "\n";
print "$ENV{'HTTP_COOKIE'}";

下のリンクをクリックして、書き込みと読み込みをテストしてみてください。別窓が開きます。 有効期限は設定していないのですぐに削除されます。

writecookie.cgi … 複数のクッキーを書き込みます
readcookie.cgi … クッキーを読み込みます
writecookie2.cgi … 別のクッキーを書き込みます

writecookie.cgi を実行すると、 クッキー読み込み結果は、次のようになりました。

Var1=Val1; Var2=Val2

; で区切られて1つの文字列としてクッキーを受け取りました。 最後の ; が無いっていうのがちょっと不思議ですけど (^^; でも、このような形でクッキーを受け取れることが分かれば、 これを連想配列にすることは難しくないですね。

続いて、writecookie2.cgi を実行して別のクッキーを書き込んでみます。 writecookie.cgi と writecookie2.cgi は同じフォルダにあるのでクッキーは共有されるはずです。 そこで、クッキーの衝突を確認するために、 writecookie2.cgi では Var2 を上書き、Var3 を新規に追加します。 試してみると、クッキーは次のようになりました。

Var1=Val1; Var2=Val300; Var3=Val4

なるほど!クッキーはやっぱり共有されるけども、 上書きされるのは 名前 が同じクッキーだけですね。 writecookie2.cgi は Var1 は出力していませんが、ちゃんと残ってますし、 Var3 もちゃんと追加されています。

前回、クッキーの衝突っていうことを研究したときは、 クッキーが衝突して上書きされるっていうのを確認しましたが、 あのときは 名前=値 でなく単に 文字列 としてクッキーを書き込んでいました。 それが上書きされていたということは、名前=値 でなく単に 文字列 をクッキーに出力した場合、 「名前の無い値」として扱われるからなのでしょう。 なので、「名前の無い値」の衝突が起きていたわけです。 (ちなみに、「値の無い名前」として処理されていたとしたら、衝突は起きなかったはずですよね。)

正式な 名前=値 の形式でクッキーを書き込めば、名前が衝突しない限り、 クッキーが上書きされることはありません。 名前が衝突しちゃったら、上書きされちゃいますのでこの点は今までと同様に注意が必要ですね。

クッキー処理ライブラリの修正

さて、クッキー処理の正式なやり方は分かりました。 では、今までのやり方は この正式なやり方に従っていたか?というと、名前=値 というのを指定していなかったという 問題がありました。でも、上の実験で分かったように、 「名前の無い値」として処理されているため、ちゃんと動いていたわけでした。 微妙なところですが、一応は正式なやり方に従っていたといえるかな?というところです (^^;

しかし、= や ; を処理していなかったため、これらが入ると正しく動かないという不具合はありました。 この修正が必要です。 あと、複数のクッキーを保存する場合、自力でそれを1つの文字列にして保存していましたが、 複数の Set-cookie を出力するやり方に変更します。 これらの修正を行いましょう。

なお、複数の 名前=値 のペアを自力で1つにまとめて 1つのクッキーとして保存するという方式は、間違いではありませんが、 メリットは Set-cookie の数が減って出力バイト数が減るくらいしかありませんので、やめることにしましょう。

特殊文字のエスケープ方法

= や ; を %〜 に変換するっていうのを行う必要があります。 単に = と ; に対してだけこれをやるなら難しくないのですが、 全角文字が入っている場合、これも %〜 に変えてやる必要がありますね。 なぜかというと、全角文字の中に隠れた = や ; があるかも知れない?と思いますので。 さらに細かいことを言えば、改行コードなどが入っていたらこれも変換しないといけません。 そうしないと、HTTPヘッダ出力時に改行されてしまって、正しく動きませんよね。 他にも、変換しなきゃいけない文字があるかも…と考えると、 それじゃあ全部 %〜 に変換しちゃえば安全だ!とも思いますが、 そうすると文字列の長さが3倍になっちゃうし、 英数字は変換しなくても確実に安全だし、それじゃ英数字以外を全部変換にしようかな… とか、いろいろ考えてしまいます。

そんなことを考えていて、気が付きました。 この、やりたい処理って、実は、フォームからデータが送られてくるときの、 URLエンコードっていうやつと同じことですよね? フォームから送られてくるデータは、= や & や全角文字など、特殊な文字が %〜 に変換されて 送られてくるんでした。この変換をURLエンコードというんでした。 そして、CGIプログラム側ではそれを戻してあげるっていう処理をしていました。 これをデコードというんでした。 ですから、URLエンコード、URLデコードをするルーチンを作れば、 それがそのまま、クッキーの特殊文字をエスケープする方法に使えます。 せっかく、特殊文字を変換するエンコード&デコードルーチンを作るのであれば、 一般的なルールに従ったエンコード&デコードルーチンにしましょう。 そうすれば、他のところで活用できそうですし。 ということで、このURLエンコード&URLデコード処理を研究しましょう。

さて、デコードの方は既に研究済みですね。

$s =~ tr/+/ /;
$s =~ s/%([A-Fa-f0-9][A-Fa-f0-9])/pack("C", hex($1))/eg;

エンコードの方は、今回初めてですが、変換のルールは次の通りです。

(1) 半角英数字(A〜Z、a〜z、0〜9)と記号(*-.@_)は変換せずにそのままにします。
(2) 半角スペースは + に変換します。
(3) その他の文字は %(16進数2桁の文字コード) に変換します。

このルールに従って変換を行います。 そのために、まずは、 1つの文字を%(16進数2桁の文字コード)に変えるという処理を考えましょう。 これ、以前、透過gifファイルの内容をバイナリで表示するっていう研究をしたときに 似たことをしています。ある文字が1文字だけ $c に入っているとき、次のように すれば %〜 に変換できます。

sprintf("%%%02X, ", ord($c));

% が沢山並んでいますが、最初の %% が %、次の%02X が2桁の16進数値を表しています。 ord 関数は、文字を対応するコードに変換するものでした。 これで、1文字の変換の方法は分かりましたね。

さて、ある文字列があったときに、ルールに従って、 ある文字はそのまま、ある文字は変換するっていうのはどうしたらいいのか。 それには、デコードと同じで s/〜/〜/eg を使います。答えは、次のようになります。

$s =~ s/([^a-zA-Z0-9*\-.\@_ ])/sprintf("%%%02X",ord($1))/eg;
$s =~ tr/ /+/;

1行目の [^〜] のところは、〜以外の1文字っていう意味になります。 最初の ^ が、〜以外の という意味になります。 〜のところが、そのままにして変換しない英数字と記号を表しています。 変換しない記号は、*-.@_ の5個ですが、- と @ は特殊な意味らしく \ でのエスケープが必要です。 また、スペースは %〜 でなくて + に変換するので、除いています。 で、結局、[〜] で、変換したい対象の文字 を表しています。ややこしいかな? その文字が $1 に入れられて、その後ろの sprintf で作られた文字に変換されます。 変換後の文字列は、上で先に考えたものですね。 難しいですが、たったの1行ですからさすがPerlですね。 で、スペースを + に変えて終わり。この変換は必ず最後に行います。 そうしないと、変換した + が %〜 に変換されなおされてしまいますから。

それじゃ、これをテストしてみましょう。 テスト用のCGIプログラムを作りました。

enctest.cgi
#!/usr/bin/perl

print "Content-type: text/html\n\n";

# 送られてきたデータを受け取ってフォーム名の text= を削除します
$s =  $ENV{'QUERY_STRING'};
$s =~ s/text=//g;

if ($s eq "") { exit; }

# デコードします
$d = $s;
$d =~ tr/+/ /;
$d =~ s/%([A-Fa-f0-9][A-Fa-f0-9])/pack("C", hex($1))/eg;

# エンコードします
$e = $d;
$e =~ s/([^a-zA-Z0-9*\-.\@_ ])/sprintf("%%%02X",ord($1))/eg;
$e =~ tr/ /+/;

# 一致するかテストします
if ($s eq $e) { $msg = "一致しました。成功!"; }
else          { $msg = "一致しません。失敗です!!"; }

print << "EOM";
<tt>
(A)送られた文字列:<br>
$s<br>
<br>
(B)デコードしたもの:<br>
$d<br>
<br>
(C)エンコードしたもの:<br>
$e<br>
<br>
(A)と(C)は…:<br>
$msg
EOM

次のフォームに適当になにか文字列を入れて、送信してみてください。 まず、ブラウザがURLエンコードした文字列がCGIプログラムに渡されます。 これをデコードして、元の文字列に戻します。 さらに、URLエンコードし直します。 これが、ブラウザから送られてきたものに一致すれば、URLエンコード成功です。 テストしやすいように、結果をIFRAMEの中に表示するようにしています。

[ URLエンコード・URLデコードのテストフォーム ]

よし!URLエンコード、URLデコードもばっちりです。

これで準備が整いました。クッキー処理ライブラリの修正を行いましょう!

作成したCGIプログラム

writecookietest.cgi (クッキー書き込みテスト)
#!/usr/bin/perl

require 'cookie.pl';

$plab::Cookie{"Var1"} = "全角のテスト";
$plab::Cookie{"Var2"} = "記号のテスト /*-+./.,]:;[\@^-|~={`}*+_?!\"#\$\%&'()";

plab::writecookie();
print "Content-type: text/html\n";
print "\n";
print "書き込みました。";
writecookietest2.cgi (クッキー上書き&追加テスト)
#!/usr/bin/perl

require 'cookie.pl';

$plab::Cookie{"Var2"} = "上書きテスト";
$plab::Cookie{"Var3"} = "追加テスト";

plab::writecookie();
print "Content-type: text/html\n";
print "\n";
print "書き込みました。";
readcookietest.cgi (クッキー読み込みテスト)
#!/usr/bin/perl

require 'cookie.pl';

print "Content-type: text/html\n";
print "\n";

foreach (keys(%plab::Cookie)) {
	print "$_ = $plab::Cookie{$_}<br>\n"; 
}
cookie.pl (クッキー処理関数ライブラリ)
# v 1.10

# (c) PERL-LABO
# http://www.perl-labo.org/

package plab;

# 使い方
#   %plab::Cookie でクッキーにアクセス
#   plab::writecookie(); (HTTPヘッダ出力時に必ず呼ぶ)
# 注意
#   readcookie() は自動的に呼ばれます。

readcookie();	# 最初にクッキーの読み込みを行う
%Cookie;  	# plabパッケージグローバルハッシュ
$Cookie_read;	# readcookieが呼ばれたら1

# クッキーの読み込み
sub readcookie
{
	# 念のため上書き読み込みを禁止する
	if ($Cookie_read == 1) {
		return;
	}
	$Cookie_read = 1;

	# var1=val1; var2=val2; var3=val3
	# という形。最後の ; が無いことに注意。
	local $cookiestring = $ENV{"HTTP_COOKIE"};

	# ; で区切る
	local @pairs = split('; ', $cookiestring);

	# = で区切って連想配列に入れる
	foreach (@pairs) {
		local ($var, $val) = split('=', $_);
		# 名無しの値は処理しません。
		if ($val ne "") {
			$var = urldecode($var);
			$val = urldecode($val);
			$Cookie{$var} = $val;
		}
	}
}

# クッキーHTTPヘッダ出力
# 引数 (有効時間)
# 有効期限は経過時間で与えます。
# 0のときは有効期限設定無し。
# -1または引数無しのときは10年。
# HTTPヘッダを出力するのでHTTPヘッダ出力の最初などに呼びます。
sub writecookie
{
	local($n, $i);
	local $expire_delta_hour;
	local($expires, @t, @m, @w);
	local @key;
	local $ncookie;

	$expire_delta_hour = $_[0];
	if ($expire_delta_hour eq "" || $expire_delta_hour == -1) {
		$expire_delta_hour = 24*365*10;  # 10 years
	}

	# 有効期限文字列の作成
	if ($expire_delta_hour != 0) {
		@t = gmtime(time() + $expire_delta_hour*60*60);
		@m = ('Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
		@w = ('Sun','Mon','Tue','Wed','Thu','Fri','Sat');
		$expires = sprintf(" expires=%s, %02d-%s-%04d %02d:%02d:%02d GMT;",
				$w[$t[6]], $t[3], $m[$t[4]], $t[5]+1900, $t[2], $t[1], $t[0]);
	}

	# クッキーを出力します
	while (local($var, $val) = each(%Cookie)) {
		local $var2 = urlencode($var);
		local $val2 = urlencode($val);		
		print "Set-Cookie: $var2=$val2;$expires\n";
	}
}

# URLエンコード
sub urlencode
{
	local $s = $_[0];
	$s =~ s/([^a-zA-Z0-9*\-.\@_ ])/sprintf("%%%02X",ord($1))/eg;
	$s =~ tr/ /+/;
	return $s;
}

# URLデコード
sub urldecode
{
	local $s = $_[0];
	$s =~ tr/+/ /;
	$s =~ s/%([A-Fa-f0-9][A-Fa-f0-9])/pack("C", hex($1))/eg;
	return $s;
}

1;

実行結果

writecookietest.cgi … クッキーを書き込みます。
writecookietest2.cgi … クッキーを書き込みます。
readcookietest.cgi … クッキーを読み込みます
readcookie.cgi … 生のクッキーを表示します

解説

動作確認

writecookietest.cgi で、Var1とVar2という名前のクッキーを書き込みます。 内容はテストのため、全角文字だったり記号だったりです。 writecookietest2.cgi は、Var2とVar3という名前のクッキーを書き込みます。 これで値の上書きと新しい値の追加のテストができます。 readcookietest.cgi で書き込まれたクッキーを 名前=値 という形で表示します。 readcookie.cgi は生のクッキー文字列を表示しますので、 ちゃんとURLエンコードされた状態で保存されているか、といったことが確認できます。

ちゃんと動きました。

ライブラリ仕様変更

今まで、クッキー処理を行う場合は最初に readcookie 関数を呼び出すことになっていましたが、 今回、これをしなくてもいいようになっています。 ライブラリは require で他のファイルに読み込まれますが、 読み込まれたときに sub 以外のところが実行されます。 そこで、ファイルの先頭付近に readcookie(); と書いておけば、 自動的に readcookie 関数が呼び出されることになります。 ということで、readcookie 関数は呼び出さなくてよくなりました。 ただし、呼ばなくてもいいというだけで、 呼んではいけないというわけではないので互換性は保たれています。

ということで、クッキーの保存方法の仕様が変わってしまったので、 今までの cookie.pl で書き込んだクッキーには、 今回修正した cookie.pl ではアクセスできません。 …こういう仕様変更は良くありませんね。未熟です。 クッキーについてはよく分かっていませんでした。反省。恥ずかしい…。

動きました

無事、動きました。 全角文字や記号などもちゃんと保存できるようになりました。 目的達成!あとは path、domain 属性を指定できるようになれば完成といえそうです。 ただ、互換性が無くなってしまったのは良くなかったですね。反省。

Perl/CGI研究室 'PERL-LABO' TOPへ
戻る(History.Back)

Copyright (c) 'PERL-LABO' All Rights Reserved.  リンクフリーです。