落ち穂拾い(その2)

今回直すこと

前回は、 ファイルがないときにステータスコード404を返すこと、 ディレクトリが指定されたときに301を返すことに対応しました。

残作業として、以下のふたつがあります。

今回はこの2点に対する対応を行います。

URLエンコードで何が起きているのか

ディレクトリ名やファイル名に空白や日本語が含まれると、 ブラウザはそれを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文字を扱うため、ユーザエージェントが次の規則に従うことを推奨する。

  1. 与えられた各文字を、UTF-8 ([RFC2279]参照)の1バイトあるいは複数バイトで表現する。
  2. 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アプリケーション」に踏み込んでいく予定です。

いつになるかはわかりませんが、気長にお待ちくださいませ。



前のページ | 次のページ | ひとつ上のページに戻る | トップページに戻る