WebAPIのステートレスなCSRF対策

Jerseyのバージョン1.9.1で追加されたCSRFをステートレスに防ぐフィルタが興味深かったので、そのメモです。

CSRF対策の手法

通常、CSRF攻撃を防ぐにはトークンを使う方法があります。サーバがクライアントにトークンを発行して、クライアントは発行されたトークンをクエリパラメータなどの形でリクエストに付与します。サーバはトークンが付与されていないリクエストを実行しません。攻撃者は発行されたトークンを知らないため、リクエストを実行できないという寸法です。

CSRF対策とステート

ただし、この方法ではトークンの取得〜リクエストの発行にステートができるため、WebAPIがステートフルになってしまうという問題があります。RESTベースのWebAPIはやはりステートレスに作りたいところです。
そこで、JerseyのCSRF対策フィルタはステートレスになるよう作られています。

JerseyのCSRF対策フィルタ

フィルタのJavaDocソースコードはこちら。
http://jersey.java.net/nonav/apidocs/1.10/jersey/com/sun/jersey/api/container/filter/CsrfProtectionFilter.html
http://java.net/projects/jersey/sources/svn/content/trunk/jersey/jersey-server/src/main/java/com/sun/jersey/api/container/filter/CsrfProtectionFilter.java?rev=5545

フィルタのソースを見ると内容はとてもシンプルです。HTTPリクエストにX-Requested-Byヘッダがあればリクエストをそのまま通す、なければHTTPステータスに400(BadRequest)を返却します。また、HTTPメソッドGET,HEAD,OPTIONSは処理の対象から外しています。

何故これでCSRF攻撃が防げるのか

フィルタのJavaDocで以下のPaperを参照しています。
http://www.nsa.gov/ia/_files/support/guidelines_implementation_rest.pdf
http://seclab.stanford.edu/websec/csrf/csrf.pdf

form, iframe, imageなどからのリクエストではHTTPリクエストに独自のヘッダを付与することができません。独自のヘッダをつけるにはXMLHttpRequestを使うしかないわけです。そしてXMLHttpRequestを使う場合にはSame Origin Policyが適用されるため攻撃者のドメインからHTTPリクエストがくることはない、ということのようです。

フィルタを試す

実際にフィルタを使ってみます。まずフィルタをweb.xmlサーブレットに登録します。

		<init-param>
			<param-name>com.sun.jersey.spi.container.ContainerRequestFilters</param-name>
			<param-value>com.sun.jersey.api.container.filter.CsrfProtectionFilter</param-value>
		</init-param>

テストに使うリソースは以下の通り。

@Path("/test")
public class TestResource {
	
	@GET
	public Response get() {
		return Response.status(200).entity("Hello, World!").build();
	}
	
	@POST
	public Response post(String message) {
		return Response.status(201).entity(message).build();
	}
	
}

テストは実際にHTMLを書いてAjaxを使うのも良いですがJerseyにはClientの実装があるためそれを使います。

public class TestResourceTest {
	
	@Test
	public void testGetWithoutHeader() {
		Client client = Client.create();
		WebResource resource = client.resource("http://localhost:8080/test-jersey-csrf/test/");
		ClientResponse response = resource.get(ClientResponse.class);
		// GET はフィルタ対象外なので 200 (Success)
		Assert.assertEquals(response.getStatus(), 200);
	}
	
	@Test
	public void testPostWithoutHeader() {
		Client client = Client.create();
		WebResource resource = client.resource("http://localhost:8080/test-jersey-csrf/test/");
		ClientResponse response = resource.entity("hogehoge").post(ClientResponse.class);
		// X-Requested-By がなければ 400 (BadRequest)
		Assert.assertEquals(response.getStatus(), 400);
	}
	
	@Test
	public void testPostWithHeader() {
		Client client = Client.create();
		WebResource resource = client.resource("http://localhost:8080/test-jersey-csrf/test/");
		ClientResponse response = resource.header("X-Requested-By", "true").entity("hogehoge").post(ClientResponse.class);
		// X-Requested-By がついていれば 201 (Created)
		Assert.assertEquals(response.getStatus(), 201);
	}
	
}

アプリケーションをサーブレットにデプロイした状態でテストを実行すると全てPASSしました。

まとめ

CSRFを防ぐ方法として、通常のトークンを使うやり方ではWebAPIがステートフルになってしまうという問題点がありました。それに対し、HTTPリクエストに独自ヘッダをつけるやり方ではステートレスに防ぐことができます。

この方法の注意点としてはWebAPIをコールするのがXMLHttpRequest経由に限られる、ということです。もしXMLHttpRequest以外からもコールされるようなWebAPIを作る場合には、やはりトークンを使ってステートフルにする方法をとるしかないと思います。

また、GET, HEAD, OPTIONSがフィルタ処理の対象外になっていることも注意すべきです。そもそもWebAPIの設計として、それらのHTTPメソッドで副作用のある処理を実行するように作るべきではないです。