LWP::Authen::OAuthでUTF-8を含むrequestを投げると認証に失敗する件

LINEで送る
Pocket

UTF-8(日本語)が通らない

LWP::Authen::OAuthはPerlでOAuth1.0の認証が必要なHTTP requestを処理するのに便利なLWP::UserAgent Wrapperです。

例えば以下のコードでTwitter APIが叩けます。1割と楽です。

#!/usr/bin/perl

use strict;
use warnings;

use URI;
use LWP::Authen::OAuth;

my $text = "hoge";


my $ua = LWP::Authen::OAuth->new();
$ua->oauth_consumer_key('key');
$ua->oauth_consumer_secret('key_secret');
$ua->oauth_token('token');
$ua->oauth_token_secret('token_secret');

my $uri = URI->new('https://api.twitter.com/1.1/search/tweets.json');
$uri->query_form({'q'=>$text});

my $ret = $ua->get($uri);
print $ret->content();

ところが、

my $text = "hoge";

だと通りますが、

my $text = "ほげ";

だと

{"errors":[{"message":"Could not authenticate you","code":32}]}

などと言われて呼び出しを拒否されます。ちなみに以下のようにUTF8フラグ(後述)をセットしても同じ結果です。

my $text = "ほげ";
utf8::decode($text);

原因

OAuth1.0では、リクエスト(GETのWuery StringやPOSTのBody)は指定された順序に並べた上で連結し、連結した文字列に対して署名を行わなければなりません。

LWP::Authen::OAuthでは適当に署名してからリクエストを投げてくれますが、この部分に問題があります。具体的には、リクエストにUTF-8文字列が含まれているときに、PerlのUTF8フラグを処理し損なって文字列が壊れます。壊れた文字列では正しい署名が生成できないので、認証が通りません。

UTF8フラグとは

これについて語ると偉い方から沢山ご指摘が飛んできそうなので、先達の記事をご参照ください。

(参考)壊れる順序

$uri->query_form({'q'=>$text});
 
my $ret = $ua->get($uri);

が、LWP/UserAgent.pmで

sub get {
    require HTTP::Request::Common;
    my($self, @parameters) = @_;
    my @suff = $self->_process_colonic_headers(\@parameters,1);
    return $self->request( HTTP::Request::Common::GET( @parameters ), @suff );
}

とHTTP::Requestが生成されて、LWP/Authen/OAuth.pmの

sub sign_hmac_sha1
{
    my( $self, $request ) = @_;

    my $method = $request->method;
    my $uri = URI->new( $request->uri )->canonical;

までやってきたところで、一旦$request->uri(文字列)に展開された後、もう一度URI Objectが作られます。この後、

    push @params, $uri->query_form;

で一旦key-valueとして取り出されて、

sub oauth_encode_parameter
{
    my( $str ) = @_;
    return URI::Escape::uri_escape_utf8( $str, '^\w.~-' ); # 5.1
}

でuri_escapeされるのですが、ご覧の通りここはUTF8フラグが立っていることが期待されています。

ところが、$uri->query_formでkey-valueが取り出された際には、それがlaten-1の文字列範囲外であってもUTF8フラグが立たず、単なるバイト列となってしまうようです。2

ということで、結局URI::Escape::uri_escape_utf8()で文字列が破壊されてしまいまい、正しい署名が生成されません。

(参考)URI.pmの挙動

#!/usr/bin/perl

use strict;
use warnings;

use URI;
use HTTP::Request;

my $val = "ほげ";
utf8::decode($val);
judge($val);

my $uri = URI->new('http://example.com/method');
$uri->query_form({'q'=>$val});
my $request = HTTP::Request->new(GET => $uri);

judge($request->uri);

my $uri2 = URI->new($request->uri);
my @params = $uri2->query_form;
judge($params[1]);

print "escaped: ",URI::Escape::uri_escape_utf8($params[1]),"\n";

sub judge{
    my $string = shift;
    if (utf8::is_utf8($string)){
        print "    utf8: $string\n";
    } else {
        print "not utf8: $string\n";
    }
}
$ perl uri-test.pl
Wide character in print at uri-test.pl line 28.
    utf8: ほげ
not utf8: http://example.com/method?q=%E3%81%BB%E3%81%92
not utf8: ほげ
escaped: %C3%A3%C2%81%C2%BB%C3%A3%C2%81%C2%92

※一度URI文字列に展開してから再度パラメータを披露とUTF8フラグが落ちている。
この状態でURI::Escape::uri_escape_utf8()すると期待した結果(%E3%81%BB%E3%81%92)と異なる結果が得られる。

解決方法

LWP::Authen::OAuth.pmを以下のように修正すれば動いてくれます。折角UTF-8の事を考慮してくれたのに……と悲しくなってしまいますが。

sub oauth_encode_parameter
{
    my( $str ) = @_;
    return URI::Escape::uri_escape( $str, '^\w.~-' ); # 5.1
}

もしくは、一旦$strにUTF8フラグを立ててあげるのが、心意気としては正しいのかも知れません。しかしそれもなんだか奇妙な気がします。

そもそも今更OAuth1.0を使わないというのが適切な解決策であるように思われます。OAuth2.0にはリクエストの署名などと言うめんどくさい処理がなくなるため、少なくともこのような署名が適切に生成できない問題からは解放されます。

ですが、Twitter REST API 1.1は現在のところOAuth2.0を受け付けてくれません。ということで、当面はこういうごまかしでやり過ごすしかないようです。Hmm…

  1. アクセストークンの取得も出来ますが、割愛。 []
  2. $uri->query_form($param);の場合はUTF8フラグがあってもなくても適切に処理してくれるのですが。 []