前回は、 ファイルがないときにステータスコード404を返すこと、 ディレクトリが指定されたときに301を返すことに対応しました。
残作業として、以下のふたつがあります。
今回はこの2点に対する対応を行います。
ディレクトリ名やファイル名に空白や日本語が含まれると、 ブラウザはそれをURLエンコードしてサーバに送ります。
そこで具体的に何が起きているのかを、 以前作った Server01.javaで確認してみましょう。
ブラウザからは、以下のHRLをリクエストします。
http://localhost:8001/日本語 ディレクトリ/日本語 ファイル名.cgi?hoge=日本語
「日本語」と「ディレクトリ」および「日本語」と「ファイル名」 の間に入っているのは半角のスペースです。 また、今回、実験のため、 ?以降のクエリストリングも付けておきました。 クエリストリングをつけるので、一応拡張子は.cgiにしています(意味はないですが)。
上記のURLをブラウザのアドレスバーに入力し、Server01を叩いてみます。
うちのFirefox(23.0.1)では以下のserver_recv.txt が出力されました。1行目は長すぎるので、適当に改行と空白で整形しています。
1: GET /%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83 %88%E3%83%AA/%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%95%E3%82%A1%E3%82%A4%E3%83 %AB%E5%90%8D.cgi?hoge=%E6%97%A5%E6%9C%AC%E8%AA%9E HTTP/1.1 2: Host: localhost:8001 3: User-Agent: Mozilla/5.0 (Windows NT 6.0; rv:23.0) Gecko/20100101 Firefox/23.0 4: Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 5: Accept-Language: ja,en-us;q=0.7,en;q=0.3 6: Accept-Encoding: gzip, deflate 7: Connection: keep-alive 8:
IE9だと以下です。 上と同様、1行目は整形しています。
1: GET /%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83 %88%E3%83%AA/%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%95%E3%82%A1%E3%82%A4%E3%83 %AB%E5%90%8D.cgi?hoge=日本語 HTTP/1.1 2: Accept: image/gif, image/jpeg, image/pjpeg, application/x-ms-application, (以下略) 3: Accept-Language: ja 4: User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Trident/5.0; (以下略) 5: Accept-Encoding: gzip, deflate 6: Host: localhost:8001 7: Connection: Keep-Alive 8:
――見てわかるとおり、IEとFirefoxで挙動が異なるのがいやらしいところです。
URLエンコードとは、ざっくり言えば、元の文字列をバイト単位で解釈し、 %の後ろにその16進表現をつなげたものです ※1。 よって、日本語では、元の文字列がどのようなエンコーディングであったか( たとえばShift-JISかUTF8かEUCか)によって結果が変わってきます。
上のURLエンコードの結果を見ると、クエリストリング以外の部分、 つまり「日本語 ディレクトリ/日本語 ファイル名.cgi」の部分までは、 Firefox, IEともにUTF8でエンコードしたものをURLエンコードしています。 Web上でURLエンコード/デコードできるサイトはいくらでもありますので (Googleで先頭にきたのは ここ)、 そういうサイトで試すことで確認可能です。
現状のWebサーバはいずれにしてもクエリストリングを解釈しませんから、 パスの部分については、 UTF8としてURLデコードすればよいということになります。
FirefoxとIEがそうなっているのはいいとして、 それってどこかに規格として文書化されてるの? というのが気になるところですが、 W3Cの HTML仕様のドキュメントに記載があります。
こうした場合に非ASCII文字を扱うため、ユーザエージェントが次の規則に従うことを推奨する。
- 与えられた各文字を、UTF-8 ([RFC2279]参照)の1バイトあるいは複数バイトで表現する。
- URIのエスケープ機構により、このバイトをエスケープする。すなわち、各バイトをバイト値の十六進表現HHを用いて「%HH」で表す。
今回は、もうひとつの修正点、 ディレクトリトラバーサル対応も同時に入れるので、 ソースは後で載せます。
上で、?より後ろのクエリストリング部分については、 IEとFirefoxで挙動が異なると書きました。
挙動が異なるのは困ったものですが、GETのクエリストリングは Googleをはじめ各種検索エンジン等で検索文字列を渡したりする時に 広く使われています。ブラウザごとに挙動が異なって困らないでしょうか。
実は、上の例では ブラウザのアドレスバーに直接日本語のクエリストリングを打ち込みましたが、 HTMLでform要素を書いてそのmethod属性をgetにする、という方法では、 IE, Firefoxともに、そのHTMLの文字コードに合わせたコードで送られます。
で、それはどこかの規格に文書化されてるの? という疑問が当然出てくるわけですが―― どうも文書化された挙動ではないようです。 少なくとも私は知りません。ご存知の人がいたら教えてください。
なお、HTMLのform要素には、accept-charsetという属性があり、 これを使うと、送信時の文字コードを指定できることになっています。 ただ、今試したのですが、これはFirefoxでは効きましたが IE9では効きませんでした。
「../」のようなパスが渡されたとき、 DOCUMENT_ROOTの外のファイルを返してしまわないよう対策します。
今回はJava7を使っているので、 java.nio.fileパッケージのPathクラスのtoRealPath()を使用して、 パスをいったん絶対パスに変換し、 それがDOCUMENT_ROOTで始まっていることを確認します。
以下、ソースです。まずはServerThread.javaです。
import java.io.*; import java.net.*; import java.nio.file.*; class ServerThread implements Runnable { private static final String DOCUMENT_ROOT = "C:\\maebashi\\homepage"; private static final String ERROR_DOCUMENT = "C:\\webserver\\error_document"; private static final String SERVER_NAME = "localhost:8001"; private Socket socket; @Override public void run() { OutputStream output = null; try { InputStream input = socket.getInputStream(); String line; String path = null; String ext = null; String host = null; while ((line = Util.readLine(input)) != null) { if (line == "") break; if (line.startsWith("GET")) { path = URLDecoder.decode(line.split(" ")[1], "UTF-8"); String[] tmp = path.split("\\."); ext = tmp[tmp.length - 1]; } else if (line.startsWith("Host:")) { host = line.substring("Host: ".length()); } } if (path == null) return; if (path.endsWith("/")) { path += "index.html"; ext = "html"; } output = new BufferedOutputStream(socket.getOutputStream()); FileSystem fs = FileSystems.getDefault(); Path pathObj = fs.getPath(DOCUMENT_ROOT + path); Path realPath; try { realPath = pathObj.toRealPath(); } catch (NoSuchFileException ex) { SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT); return; } if (!realPath.startsWith(DOCUMENT_ROOT)) { SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT); return; } else if (Files.isDirectory(realPath)) { String location = "http://" + ((host != null) ? host : SERVER_NAME) + path + "/"; SendResponse.SendMovePermanentlyResponse(output, location); return; } try (InputStream fis = new BufferedInputStream(Files.newInputStream(realPath))) { SendResponse.SendOkResponse(output, fis, ext); } catch (FileNotFoundException ex) { SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT); } } catch (Exception ex) { ex.printStackTrace(); } finally { try { if (output != null) { output.close(); } socket.close(); } catch (Exception ex) { ex.printStackTrace(); } } } ServerThread(Socket socket) { this.socket = socket; } }
25行目で、パスのURLデコードを行っています。
45行目で、Path#toRealPath()を使用して絶対パスを取得し、 50行目でそれがDOCUMENT_ROOTで始まっていることを確認しています (そうでなければ404扱いとしました)。 toRealPath()は実際にファイルがなければNoSuchFileExceptionを返すので、 そこで404も返しています。 ただ、実際にファイルをオープンするところ(60行目)で FileNotFoundExceptionが発生しないという保証もないので (確率は低いですが、45行目から60行目まで実行される間に ファイルが削除された場合)、再度例外のチェックは行っています。
特に変更はないですが、Main.java、Util.java、SendResponse.javaも掲載します。
Main.java:
import java.io.*; import java.net.*; import java.util.*; public class Main { public static void main(String[] argv) throws Exception { try (ServerSocket server = new ServerSocket(8001)) { for (;;) { Socket socket = server.accept(); ServerThread serverThread = new ServerThread(socket); Thread thread = new Thread(serverThread); thread.start(); } } } }
Util.java:
import java.io.*; import java.util.*; import java.text.*; class Util { // InputStreamからのバイト列を、行単位で読み込むユーティティメソッド static String readLine(InputStream input) throws Exception { int ch; String ret = ""; while ((ch = input.read()) != -1) { if (ch == '\r') { // 何もしない } else if (ch == '\n') { break; } else { ret += (char)ch; } } if (ch == -1) { return null; } else { return ret; } } // 1行の文字列を、バイト列としてOutputStreamに書き込む // ユーティリティメソッド static void writeLine(OutputStream output, String str) throws Exception { for (char ch : str.toCharArray()) { output.write((int)ch); } output.write((int)'\r'); output.write((int)'\n'); } // 現在時刻から、HTTP標準に合わせてフォーマットされた日付文字列を返す static String getDateStringUtc() { Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US); df.setTimeZone(cal.getTimeZone()); return df.format(cal.getTime()) + " GMT"; } // 拡張子とContent-Typeの対応表 static final HashMap<String, String> contentTypeMap = new HashMap<String, String>() {{ put("html", "text/html"); put("htm", "text/html"); put("txt", "text/plain"); put("css", "text/css"); put("png", "image/png"); put("jpg", "image/jpeg"); put("jpeg", "image/jpeg"); put("gif", "image/gif"); } }; // 拡張子を受け取りContent-Typeを返す static String getContentType(String ext) { String ret = contentTypeMap.get(ext.toLowerCase()); if (ret == null) { return "application/octet-stream"; } else { return ret; } } }
SendReponse.java:
import java.io.*; import java.net.*; class SendResponse { static void SendOkResponse(OutputStream output, InputStream fis, String ext) throws Exception { Util.writeLine(output, "HTTP/1.1 200 OK"); Util.writeLine(output, "Date: " + Util.getDateStringUtc()); Util.writeLine(output, "Server: Server06.java"); Util.writeLine(output, "Connection: close"); Util.writeLine(output, "Content-type: " + Util.getContentType(ext)); Util.writeLine(output, ""); int ch; while ((ch = fis.read()) != -1) { output.write(ch); } } static void SendMovePermanentlyResponse(OutputStream output, String location) throws Exception { Util.writeLine(output, "HTTP/1.1 301 Moved Permanently"); Util.writeLine(output, "Date: " + Util.getDateStringUtc()); Util.writeLine(output, "Server: Server06.java"); Util.writeLine(output, "Location: " + location); Util.writeLine(output, "Connection: close"); Util.writeLine(output, ""); } static void SendNotFoundResponse(OutputStream output, String errorDocumentRoot) throws Exception { Util.writeLine(output, "HTTP/1.1 404 Not Found"); Util.writeLine(output, "Date: " + Util.getDateStringUtc()); Util.writeLine(output, "Server: Server06.java"); Util.writeLine(output, "Connection: close"); Util.writeLine(output, "Content-type: text/html"); Util.writeLine(output, ""); try (InputStream fis = new BufferedInputStream(new FileInputStream(errorDocumentRoot + "/404.html"))) { int ch; while ((ch = fis.read()) != -1) { output.write(ch); } } } }
今回は細かい修正だけでした。
次回はPOSTを扱おうと思います。そして、POSTを使う限り、 何らかのプログラムでそれを受けなければ意味がないので、 いよいよ「Webアプリケーション」に踏み込んでいく予定です。
いつになるかはわかりませんが、気長にお待ちくださいませ。