NSURLProtocol を定義してUIWebView で安全にローカルのリソースを読み込む

UIWebView の取り扱いによってはjavascript経由でサンドボックス外のファイルシステムにアクセスできてしまうという話と対処法 - laiso - iPhoneアプリ開発グループ
http://iphone-dev.g.hatena.ne.jp/laiso/20111003/1317651353

これの続き。

カスタムリソースにはNSURLProtocolを使えば良いのじゃないか? — sklave
http://sklave.jp/logs/2011/10/4/%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AB%E3%81%AFnsurlprotocol%E3%82%92%E4%BD%BF%E3%81%88%E3%81%B0%E8%89%AF%E3%81%84%E3%81%AE%E3%81%98%E3%82%83%E3%81%AA%E3%81%84%E3%81%8B

でヒントをもらい、もっとがんばってみた。

まずPhoneGap(Apache callback) の実装を参考にした。

PhoneGapLib/Classes/PGURLProtocol.m at master from callback/callback-ios - GitHub
https://github.com/callback/callback-ios/blob/master/PhoneGapLib/Classes/PGURLProtocol.m

PhoneGap ではURL Loading の処理にホワイトリストのチェックを挟むことで外部へのアクセスを制限している。ホワイトリストはplist で管理している。
非PhoneGap 製のアプリケーションがUIWebView を直接操作している場合、このPhoneGap の枠組みを再実装したりして組込むのは重いだろうので(たぶん)以下の要件を満す最小限のコードにしてみた。

  • アプリケーションのURL 読み込みにhttp://〜 https://〜 のすべてのURLを許可する
  • file://〜 は基本的に全部はじくんだけど、自分自身がバンドルしたローカルのHTMLや画像などは許可したい

できあがったものが以下です

// MyURLProtocol.h
@interface MyURLProtocol : NSURLProtocol
@end

// MyURLProtocol.m
@implementation MyURLProtocol
+(BOOL)canInitWithRequest:(NSURLRequest *)request
{
  NSMutableArray* allowSchemes = [NSMutableArray arrayWithObjects:@"http", @"https", nil];
  NSMutableArray* allowURLs = [NSMutableArray array];
  NSArray* paths = [[NSBundle mainBundle] pathsForResourcesOfType:nil inDirectory:nil];
  [paths enumerateObjectsUsingBlock:^(NSString* path, NSUInteger idx, BOOL *stop) {
    [allowURLs addObject:[NSString stringWithFormat:@"file://%@", path]];
  }];

  BOOL goodScheme = [allowSchemes containsObject:[request.URL scheme]];
  NSString* urlString = [request.URL description];
  BOOL goodURL = [allowURLs containsObject:urlString];
  return (goodScheme == NO && goodURL == NO);
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
  return request;
}

- (void)startLoading
{
  NSLog(@"[DEBUG] startLoading: %@", self.request.URL);
}

- (void)stopLoading
{
  NSLog(@"[DEBUG] stopLoading");
}
@end

これをimport ディレクティブでも、インラインにコピペでもいいのでURL 読み込みがされる前のどこか(アプリケーション起動時でもいい)で以下のように有効化しておく。

  [NSURLProtocol registerClass:[MyURLProtocol class]];

そうすると

  [webView loadHTMLString:@"<html></html>" baseURL:nil];

  NSURL* URL = [[NSBundle mainBundle] URLForResource:@"index.html" withExtension:nil];
  [webView loadRequest:[NSURLRequest requestWithURL:URL]]; 

みたいな読み込み方をしていても、システムファイルの読み込みがされなくなる。

解説

canInitWithRequest にYES を返すと startLoading/stopLoading にリクエストの読み込み処理が引き継がれる。なのでここで何もしないとレスポンスが空になるという具合。
ちなみにPhoneGap では独自エラーレスポンスを返していた。

https://github.com/callback/callback-ios/blob/master/PhoneGapLib/Classes/PGURLProtocol.m#L52

- (void) startLoading
{    
    //NSLog(@"%@ received %@ - start", self, NSStringFromSelector(_cmd));
    NSURL* url = [[self request] URL];
    NSString* body = [gWhitelist errorStringForURL:url];

    PGHTTPURLResponse* response = [[PGHTTPURLResponse alloc] initWithUnauthorizedURL:url];
    
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    
    [[self client] URLProtocol:self didLoadData:[body dataUsingEncoding:NSASCIIStringEncoding]];

    [[self client] URLProtocolDidFinishLoading:self];                
    
    [response release];    
}