jerseyのサロゲートペアに関するバグ

現象

POSTするテキストなどにU+10000以上のUTF-8文字(サロゲートペアに変換される文字)が含まれると、OAuthのSHA-1署名が不正なものになる。

まずは、OAuthの署名をやってるメソッドのコード*1です。

OAuthSignature.java

78  public class OAuthSignature {

90 public static String generate(OAuthRequest request,
91 OAuthParameters params, OAuthSecrets secrets) throws OAuthSignatureException {
92 return getSignatureMethod(params).sign(elements(request, params), secrets);
93 }

105 public static void sign(OAuthRequest request,
106 OAuthParameters params, OAuthSecrets secrets) throws OAuthSignatureException {
107 params = (OAuthParameters)params.clone(); // don't modify caller's parameters
108 params.setSignature(generate(request, params, secrets));
109 params.writeRequest(request);
110 }

122 public static boolean verify(OAuthRequest request,
123 OAuthParameters params, OAuthSecrets secrets) throws OAuthSignatureException {
124 return getSignatureMethod(params).verify(elements(request, params), secrets, params.getSignature());
125 }

jerseyのOauthモジュールで署名を扱ってるのはこのOAuthSignatureクラスなんですが、sign(), verify()メソッドから呼ばれているelementsメソッドで、URIとかリクエストの中身を正規化しています。

230  private static String elements(OAuthRequest request,
231 OAuthParameters params) throws OAuthSignatureException {
232 // HTTP request method
233 StringBuilder buf = new StringBuilder(request.getRequestMethod().toUpperCase());
235 // request URL, see section 3.4.1.2 http://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1.2
236 buf.append('&').append(UriComponent.encode(constructRequestURL(request).toASCIIString(),
237 UriComponent.Type.UNRESERVED));
239 // normalized request parameters, see section 3.4.1.3.2 http://tools.ietf.org/html/draft-hammer-oauth-10#section-3.4.1.3.2
240 buf.append('&').append(UriComponent.encode(normalizeParameters(request, params),
241 UriComponent.Type.UNRESERVED));
243 return buf.toString();
244 }

このあたりには問題がなくて、UriComponent.encode()メソッドが問題。

UriComponent.java

252  public static String encode(String s, Type t, boolean template) {
253 return _encode(s, t, template, false);
254 }

274  private static String _encode(String s, Type t, boolean template, boolean contextualEncode) {
275 final boolean[] table = [t.ordinal()];
277 StringBuilder sb = null;
278 for (int i = 0; i < s.length(); i++) {
279 final char c = s.charAt(i);
280 if (c < 0x80 && table[c]) {
281 if (sb != null) sb.append(c);
282 } else {
283 if (template && (c == '{' || c == '}')) {
284 if (sb != null) sb.append(c);
285 continue;
286 } else if (contextualEncode) {
287 if (c == '%' && i + 2 < s.length()) {
288 if (isHexCharacter(s.charAt(i + 1)) &&
289 isHexCharacter(s.charAt(i + 2))) {
290 if (sb != null)
291 sb.append('%').append(s.charAt(i + 1)).append(s.charAt(i + 2));
292 i += 2;
293 continue;
294 }
295 }
296 }
298 if (sb == null) {
299 sb = new StringBuilder();
300 sb.append(s.substring(0, i));
301 }
303 if (c < 0x80) {
304 if (c == ' ' && (t == .)) {
305 sb.append('+');
306 } else {
307 appendPercentEncodedOctet(sb, c);
308 }
309 } else {
310 appendUTF8EncodedCharacter(sb, c);
311 }
312 }
313 }

こいつがU+10000のUnicode文字つまり、java内でサロゲートペアに変換される文字を考慮してないんですな。

まあ、中身が正しいかどうかはわからんけど、サロゲートペアが含まれる場合は、以下のようにStringのlength()をそのまま使うと、U+10000以上のコードポイントの1文字が2文字に分割されてるので、バグります。


for (int i = 0; i < s.length(); i++) {

実際にgroovyで確認

ではそれを実地で確認してみましょう。javaだとjersey持ってきたりコンパイルするのがたるいので、goovyのコードです。groovyConsoleから実行するのが楽です。

@Grapes(
    @Grab(group='com.sun.jersey', module='jersey-core', version='1.14')
)
import java.net.URLEncoder;

def text="\ud83c\udc00\ud83c\udc01\ud83c\udc02"

println "text="+text
println "----"

def encoded = com.sun.jersey.api.uri.UriComponent.encode(text, com.sun.jersey.api.uri.UriComponent.Type.UNRESERVED)
println "UriComponent.encode="+encoded

def utf8andurlencoded = URLEncoder.encode(text, "UTF-8");
println "utf8andurlencoded="+utf8andurlencoded;

なにをやってるかというと、unicodeの U+1F000, U+1F001, U+1F002の3文字(unicodeで)をサロゲートペア化したテキストを用意して、JerseyのUriComponent.encodeとjava標準のURLEncodeでそれぞれUTF-8エンコード&URLエンコードした結果を表示しています。

で、結果はこれ。

text=🀀🀁🀂
      • -
UriComponent.encode=%3F%3F%3F%3F%3F%3F utf8andurlencoded=%F0%9F%80%80%F0%9F%80%81%F0%9F%80%82

jerseyの使ってるクラスのほうがエンコード失敗してますね。

というわけで、jerseyのOAuth署名処理にはコードポイントでU+10000以上の文字が含まれる場合に、リクエストパラメタの正規化のためのURLエンコードが正しく行われない問題があり、結果としてOAuth署名が正しく行われないのでした。

jersey2のほうでもまだ修正されてないみたいね。
jersey/UriComponent.java at master · jersey/jersey · GitHub

jersey2で確認するときはGrapeのところ次のように書き換えます。

@Grapes(
     @Grab(group='org.glassfish.jersey', module='project', version='2.0-m08-1')
)

*1:grepcodeの奴が見やすいので、そっちを貼ったけど、状況はjerseyの1.14でも同じ。