jerseyのサロゲートペアに関するバグ
現象
POSTするテキストなどにU+10000以上のUTF-8文字(サロゲートペアに変換される文字)が含まれると、OAuthのSHA-1署名が不正なものになる。
まずは、OAuthの署名をやってるメソッドのコード*1です。
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()メソッドが問題。
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 = ENCODING_TABLES[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 == Type.QUERY_PARAM)) {
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=🀀🀁🀂
-
-
- -
-
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でも同じ。