RM-BLOG

IT系技術職のおっさんがIT技術とかライブとか日常とか雑多に語るブログです。* 本ブログに書かれている内容は個人の意見・感想であり、特定の組織に属するものではありません。/All opinions are my own.*

【Java】【HTML】特定の文字をサーバにPOSTすると、あるブラウザでPOSTしたときだけ違う文字に書き換えられる件について

「テキストボックスやテキストエリアに文字を入力してサーバにPOSTするとき、リクエストを送るブラウザによってサーバ側が受け取る文字(正確には「文字コード」)が変わる」
というケースを発見したっていうか実際に遭遇した。
これはちょっと前にツイッターでつぶやいた以下の内容に端を発し、事象を追求していく中でわかった事実である



これが、どういう理由や背景があるものなのかわからないが、
こういう、「サーバ処理に届く前に値を書き換えられる」というようなことやられると、
基本サーバ側で待ち構えているプログラムでは手の出しようがなくなるので、ちょっと困る。
(自分たちが作った範囲以外で起きる事象に対する動作保障はできない、というのが一般的な見解だ…昨今はそれが通じないことも多いが)
とりあえず実験してわかった範囲での記録をここに挙げていく。

※実験の過程で、WikipediaUnicodeのページを参考にさせていただいた。
https://ja.wikipedia.org/wiki/Unicode
以後「Wikipedia」でという記述が合ったら上記のリンク先のページがそれだと解釈していただきたい。


 

 


↑にツイッターでつぶやいた通り、
「どうもUnicodeの私用領域付近のエリア(下位サロゲートを超えたコード値)のコードが怪しい」
というくらいまでは、実際に遭遇した事象からなんとなく目星がついていた。
具体的にはU+FDD0(Wikipedia によれば「アラビア表示形A」の範囲のコード)がU+FFFDに勝手に変えられている、という事象を確認していたことによる。
このため最初は、この「アラビア表示形A」の範囲の文字に限定して調査しようと思ったのだが、
少し範囲を広げてU+E000~U+FFFFまでの全てを対象にしようと思い至り、その方針での実験を行うことにした。
要するに下位サロゲートを超えて実際のサロゲート用の拡張エリアになる直前までの全文字(コード)を対象とした。
本当はサロゲートを除くUnicode全65,536文字を対象に調査したいところだが、
「ブラウザで入力してPOSTする」というのが(俺の力量では)手動になってしまうため、
どうしても手間がかかるということから断念した。
(今思うと、POSTする文字を書き込んだHTMLをあらかじめつくっておいて、ブラウザ立ち上げてPOSTするだけの簡単なコマンドプロンプト作ればいけたかな?まあいいか)
ただ一般的に「文字」として表現できる範囲のコードでこうした現象が頻発するとはどうしても考えづらいので、
前回の制御文字の項で取り扱ったような比較的「変なコード値」が対象になりやすいのだろう、とは予測している。
ここは暇じゃなければもうすこし深堀していきたい。




さっそく実験用のプログラムの作成だ!
ここでポイントとなるのは、サーバにPOSTしたら違う文字になったという点であるが、
実際「違う文字になった」と確認したのは、「入力画面で入力したAという文字が確認画面でBになった」というような、
あくまで作り込まれた画面インタフェース上での確認だった。
なので、「サーバはリクエストからちゃんとAを受け取ってAのままレスポンスしているが、ブラウザが画面表示する時に勝手にBにしている」
という可能性も捨てきれないわけである。
要するに、”確認画面で表示したときに初めて「違う文字になった」とわかったわけだから、どこで違う文字になってるかわからない”のである。
なので、実験用プログラムでは、サーバ側(Servlet側)で受け取った文字を加工しない状態で一度サーバ上にファイルとして出力し、
「サーバが受け取った生のリクエスト」を捉えておくようにする。
勿論、「ファイルへの出力の仕方」によっては、そこでJavaVMの「加工」が入る可能性もあるのだが、(そもそもあるのかどうか知らないが)
もはや「テキストファイル」として扱わない(バイナリファイルとして扱う)ことでなるべくその状況をクリアできるように実装考慮する。

まず「入力用の画面」であるが、これは入力してPOSTするだけだから静的HTMLで良い。
■入力画面(Test.html)

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
	<title>文字入力画面(テキストエリア版)</title>
	
	<meta HTTP-EQUIV='Cache-Control' CONTENT='no-cache'>
	<meta HTTP-EQUIV='Pragma' CONTENT='no-cache'>
	<meta HTTP-EQUIV='Expires' CONTENT='-1'>
</head>
<body>
	<h1>■文字入力画面(テキストエリア版)</h1><br/>
	<br/>
	<form action="/charcodetest/CharCodeTestServlet" method="post">
		出力ファイル名接頭辞:<input type="text" id="output_filename_head" name="output_filename_head" size="30"><br/>
		<br/>
		ここに文字を入力<br/>
		<textarea id="charcodetest_entry" name="charcodetest_entry" cols="20" rows="10"></textarea> 
		<br/>
		<input type="submit" value="GO! Submit!!">
	</form>
</body>
</html>


テキストエリアに対象の文字をべたっとはりつけてPOSTさせる。
これから試そうとしている範囲の文字(文字コード)は、
やろうと思えばIMEパッドとかでコード指定できるのだろうが、
個人的にはキーボードでの入力が割と困難なので、
先にその文字コード値をつくっておいてコピペで張り付ける方式をとる。

また、サーバ側でファイル出力するときの「ファイル名」に使用する識別情報を入力させ、
あとで見返したときに「どの範囲の文字を打った結果か」がわかるようにしておく。
なお、後述するが、サーバ側で出力するファイルには、ブラウザ名も同時に付与する。
ただこれは、User-Agentを拾ってブラウザ名を取得するロジックを使うため、要するに”サーバ側で勝手にやる”ので、
入力画面からわざわざ入力させるようにはしない。


続いて「サーバ側処理」、要するにServletである。
■サーバ側処理(CharCodeTestServlet)

import java.io.*;
import java.text.*;
import java.util.*;
import java.util.regex.*;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.*;

public class CharCodeTestServlet extends HttpServlet {

	private static final DateFormat LOG_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS");
	private static final String ENCODING_UTF8 = "UTF-8";
	private static final String ENCODING_UTF16 = "UTF-16";
	private static final String STR_PARAM_OUTPUT_FILENAME_HEAD = "output_filename_head";
	private static final String STR_PARAM_TEXTAREA_TEST = "charcodetest_entry";
	private static final String STR_URL = "/jsp/forward.jsp";
	private static final File OUTPUT_DIR = new File("D:\\temp\\output");
	private static final DateFormat OUTPUT_DIR_FORMAT = new SimpleDateFormat("yyyyMMddHHmmssSSS");
	private static final String OUTPUT_FIX_STR_IE = "IE";
	private static final String OUTPUT_FIX_STR_FF = "FireFox";
	private static final String OUTPUT_FIX_STR_GC = "Chrome";
	
	private static final String RESPONSE_STR_ERROR = "ERROR";
	private static final String RESPONSE_STR_FORWARD_STRING = "FORWARD_STRINGS";
	private static final String FORWARD_JSP = "/jsp/TestForView.jsp";
	
	public CharCodeTestServlet()
	{
	}

	public void doGet(HttpServletRequest httpservletrequest, HttpServletResponse httpservletresponse)
		throws ServletException, IOException
	{
		doProcess(httpservletrequest, httpservletresponse);
	}

	public void doPost(HttpServletRequest httpservletrequest, HttpServletResponse httpservletresponse)
		throws ServletException, IOException
	{
		doProcess(httpservletrequest, httpservletresponse);
	}

	private void doProcess(HttpServletRequest httpservletrequest, HttpServletResponse httpservletresponse)
		throws ServletException, IOException
	{
		printLog("★開始");
		try
		{
			httpservletrequest.setCharacterEncoding(ENCODING_UTF8);
			HttpSession httpsession = httpservletrequest.getSession();
			
			printLog("1.入力チェック");
			// 入力チェック
			List<String> errorList = new ArrayList<String>();
			String str = httpservletrequest.getParameter(STR_PARAM_TEXTAREA_TEST);
			if (str == null || str.length() == 0) {
				errorList.add("検証対象文字の入力がありません");
			}
			String outputFilenameHead = httpservletrequest.getParameter(STR_PARAM_OUTPUT_FILENAME_HEAD);
			if (outputFilenameHead == null || outputFilenameHead.length() == 0) {
				errorList.add("出力ファイル接頭辞の入力がありません");
			}
			if (errorList.size() > 0) {
				httpsession.setAttribute(RESPONSE_STR_ERROR , errorList);
			} else {
				httpsession.removeAttribute(RESPONSE_STR_ERROR);
				httpsession.removeAttribute(RESPONSE_STR_FORWARD_STRING);
			
				printLog("2.出力する");
				// 出力ファイルの設定
				String timestampStr = OUTPUT_DIR_FORMAT.format(new Date());
				String userAgentStr = httpservletrequest.getHeader("user-agent");
				String outputFileNameStr = timestampStr + "_" + outputFilenameHead + "_";
				if (isIE(userAgentStr)) {
					outputFileNameStr = outputFileNameStr + OUTPUT_FIX_STR_IE;
				} else if (isFirefox(userAgentStr)) {
					outputFileNameStr = outputFileNameStr + OUTPUT_FIX_STR_FF;
				} else if (isChrome(userAgentStr)) {
					outputFileNameStr = outputFileNameStr + OUTPUT_FIX_STR_GC;
				} else {
					outputFileNameStr = outputFileNameStr + userAgentStr.replace(" ","").replace(":","").replace("\\","").replace("<","").replace(">","").replace("*","").replace("|","").replace("/","").replace("\"","");
				}
				outputFileNameStr = outputFileNameStr + ".txt";
				File outputFile = new File(OUTPUT_DIR , outputFileNameStr);
				// 出力する
				outputMain(str , outputFile);
				// セッションにセット
				httpsession.setAttribute(RESPONSE_STR_FORWARD_STRING , str);
			}
			printLog("3.フォーワード");
			// forward
			RequestDispatcher requestdispatcher = httpservletrequest.getRequestDispatcher(FORWARD_JSP);
			requestdispatcher.forward(httpservletrequest, httpservletresponse);
		}
		catch(ServletException servletexception)
		{
			throw servletexception;
		}
		printLog("★終了");
	}
	
	/** 
	  * ブラウザがIEであるかどうかの判定を行います。 
	  * @param sUserAgent ユーザエージェント 
	  * @return IEであるかどうか 
	  */  
	public static boolean isIE(String sUserAgent) {  
//	    Pattern pattern = Pattern.compile(".*((MSIE)+ [0-9]\\.[0-9]).*");  
		Pattern pattern = Pattern.compile(".*Trident/7\\.0; rv:11\\.0.*"); // IE11用
	    Matcher matcher = pattern.matcher(sUserAgent);  
	    boolean bMatch = matcher.matches();  
	    return bMatch;  
	}
	
	/** 
	  * ブラウザがFirefoxであるかどうかの判定を行います。 
	  * @param sUserAgent ユーザエージェント 
	  * @return Firefoxであるかどうか 
	  */  
	public static boolean isFirefox(String sUserAgent) {  
	    Pattern pattern = Pattern.compile(".*((Firefox)+[0-9]\\.[0-9]\\.?[0-9]?).*");  
	    Matcher matcher = pattern.matcher(sUserAgent);  
	    boolean bMatch = matcher.matches();  
	    return bMatch;  
	}

	/** 
	  * ブラウザがChromeであるかどうかの判定を行います。 
	  * @param sUserAgent ユーザエージェント 
	  * @return Chromeであるかどうか 
	  */  
	public static boolean isChrome(String sUserAgent) {  
	    Pattern pattern = Pattern.compile(".*((Chrome)+/?[0-9]\\.?[0-9]?).*");  
	    Matcher matcher = pattern.matcher(sUserAgent);  
	    boolean bMatch = matcher.matches();  
	    return bMatch;  
	}
	
	private static void outputMain(String requestString,File outputFile) throws IOException {
		FileOutputStream fos = null;
		ByteArrayOutputStream baos = new ByteArrayOutputStream();
		try {
			byte[] requestStringBytes = requestString.getBytes(ENCODING_UTF8);
			baos.write(requestStringBytes,0,requestStringBytes.length);
			
			fos = new FileOutputStream(outputFile);
			baos.writeTo(fos);
			
		} catch(IOException e) {
			throw e;
		} finally {
			if (fos != null) {
				fos.close();
			}
		}
		
	}

	private static void printLog(Object obj)
	{
		StringBuilder stringbuilder = new StringBuilder();
		stringbuilder.append(LOG_FORMAT.format(new Date()));
		stringbuilder.append(" ");
		if(obj != null)
			stringbuilder.append(obj.toString());
		System.out.println(stringbuilder.toString());
	}


}



かっこつけて「チェック処理」なんか設けているが、
当然ながら今回の実験には特段必須の実装ではない(これないと後続がぬるぽで落ちまくるからとりあえず暫定で付けただけ)

HttpServletRequest#getParameterで「入力画面」のテキストエリア値を取り出して、
それを中間ファイルとして出力する。
出力に際して、タイムスタンプと、User-Agenetから判断した「ブラウザ名」
及び入力画面で入力した「識別情報」をファイル名の編集に用いる。

中間ファイルの出力にあたっては、BufferedWriterではなくByteArrayOutputStream+FileOutputStreamで行う。
BufferedWriter#writeはStringを引数にもらうものだが、
Stringで出力してしまうとJava VMが勝手に「文字寄せ」をしてしまうためである。
なので内部的はバイトコードにしてByteArrayOutputStreamにため込み、バイナリ値としてファイル出力を行う。

なお、User-Agentからのブラウザ判定ロジックは、以下ブログを参考にさせていただきました。
http://www.ilovex.co.jp/blog/system/industrysystem/javauser-agent.html

最後にforward先のJSP、いわゆる「確認画面」。
■確認画面(TestForView.jsp

<%@ page language="java" %>
<%@ page pageEncoding="UTF-8" %>
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ page import="java.util.List"%>
<%@ page import="java.util.ArrayList"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
	<title>文字入力結果確認画面</title>
	
	<meta HTTP-EQUIV='Cache-Control' CONTENT='no-cache'>
	<meta HTTP-EQUIV='Pragma' CONTENT='no-cache'>
	<meta HTTP-EQUIV='Expires' CONTENT='-1'>
</head>
<body>
	<h1>■文字入力結果確認画面</h1><br/>
	<br/>
<%
		List<String> errorList = (List<String>)session.getAttribute("ERROR");
		if (errorList != null) {
%>
			<b>エラーがあります。</b><br/>
<%
			for (int j=0; j < errorList.size(); j++) {
				String errorValue = errorList.get(j);
%>
				<%= errorValue %><br/>
<%
			}
		} else {
			String str = (String)session.getAttribute("FORWARD_STRINGS");
			String crlf = System.getProperty("line.separator");
			str = str.replace(crlf, "<br/>");
%>
			<b>入力文字をそのまま表示</b><br/>
			<%= str %><br/>
<%
		}
%>
	<br/>
	<br/>
	<a href="javascript:history.back();">もどる</a>
</body>
</html>



正直大したことはしていない。もらったレスポンスを単に表示しているだけである。




で、実験結果であるが、
実験したU+E000~U+FFFFまでの全文字を1文字ずつ並べていくと結構な行数になるので、
ある程度の範囲でまとめて結果を記載する。
OSは共通してWindows7

No文字範囲
Wikipediaより引用)コード範囲各ブラウザでリクエストしたときの中間ファイルの結果備考

IE(11.0.9) Chrome(59.0.3071) FireFox(54.0.1)
1 私用領域 U+E000~U+F8FF 変化なし この範囲は3ブラウザ共通してコード変化はなく、
入力されたままのコードをリクエストしている。
まあ「人が好きに使っていい”文字”(のための予約領域)」なので
要するに「文字」は「文字」なので、
勝手に別のコードにされたらもっと大騒ぎになっているはずとは予想するが。
2 アルファベット表示形 U+FB00~U+FB4F 変化なし 同上
3 アラビア表示形A U+FB50~U+FDCF No.1に同じ  
U+FDD0~U+FDEF U+FFFD 変化なし この範囲において、IEだけ、なぜかU+FFFDに寄せられる。
他2ブラウザはリクエストされたままのコードを保持。
U+FDF0~U+FDFF 変化なし No.1に同じ
4 字形選択子(異体字セレクタ表示形) U+FE00~U+FE0F 変化なし No.1に同じ
5 縦書き文字 U+FE10~U+FE1F 変化なし No.1に同じ
6 半記号 U+FE20~U+FE2F 変化なし No.1に同じ
7 CJK互換形 U+FE30~U+FE4F 変化なし No.1に同じ
8 小字形 U+FE50~U+FE6F 変化なし No.1に同じ
9 アラビア表示形B U+FE70~U+FEFF 変化なし No.1に同じ
同じ「アラビア」だがこっちはNo.3と比べて
3ブラウザとも変化がない。
10 半角・全角形 U+FF00~U+FFEF 変化なし No.1に同じ
(ツマランなあ…そろそろ変化起きないかなあ…ブツブツ)
11 特殊用途文字 U+FFF0~U+FFF8 U+FFFD 変化なし IEにおいて、この範囲の9文字だけがU+FFFDに変わる。
他2ブラウザはリクエストされたコード値のまま。
なんか中途半端な位置…この9文字になんの意味があるのか??
(やったッ!変化したッ!笑)
U+FFF9~U+FFFD 変化なし ここは3ブラウザとも変化しない。
U+FFFE~U+FFFF U+FFFD 変化なし この2文字も、IEだけU+FFFDに変わる。
他2ブラウザはリクエストされたコード値のまま。
他の「IEで勝手に文字寄せされる現象」を見ても、
U+FFFDまでしか有効なコード範囲として扱ってないような
節を感じるから、
そういう意味だとU+FFFDを超えるこの2文字は
そもそもIE的に扱えない文字なのかもしれない。





というわけで、以下3つの範囲の文字について、IEだけ、違うコード値になってサーバ側に送られるということがわかった。
その際、いずれもU+FFFDに書き換えられる。

  • U+FDD0~U+FDEF(アラビア表示形A)
  • U+FFF0~U+FFF8(特殊用途文字)
  • U+FFFE~U+FFFF(特殊用途文字)


なぜこの範囲が対象となっているか?という点について考察をしてみると、
U+FDD0~U+FDEF、U+FFFE~U+FFFFの範囲は、Wikipedia「不使用」という記載があり、事実上Unicodeで文字を定義していないようだ。
U+FFF0~U+FFF8も同様だが、こちらは「未使用」という記載がある(どう違うん?(・ω・))
そういう記述を見る限りでは、「使わない文字なんだから別の文字に寄せるのが正解だろ!」というような主張も、
まあなくはないのかと思えてきたところはある。

IE以外のブラウザは、同一のデータを入力してPOSTしても、コード値を勝手に寄せるようなことはしなかったので、
恐らくブラウザ側で「何もしていない」のだろう。
IEだけが、リクエスト送信の直前で、送信データの加工を行って”くれている”、というような見方が出来る。

一方で、意図的な「文字寄せ」が入っている以上、
IEを使っている以上は絶対にサーバ側にPOSTできないコード値が存在する」というのもまた事実である。
Unicode自体がそのコードに文字を定義していない以上は別に実害はなさそうではあるが、
定義済のU+FFFDに寄せてしまう分、入力としてU+FFFDがあった場合は、当然それと混在してわからなくなる。
まあU+FFFDに意味を持たせた入力をしたい、というケースも考えられないが、
仮に発生したらアプリケーション側ではどうすることもできない、不可侵領域の問題になる。

最初はこの件を「ブラウザのバグ」だと思っていたのだが、
上記のような背景があることを考えると、一概に「ブラウザのバグ」というわけでもない気がしてきた。
ただ試した3ブラウザ内ではIEだけが村八分だったので(やはりか、という感じではあるが)
この狭い範囲で多数決の考え方を適用するなら、IEのやり方はトレンドではない。
実際としてどれが正しいのか?については議論の予定がありそうであるが、
趣味の実験の範囲内ではここまでわかったことで一応よしとしよう。