vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

Angularのセキュリティ対策について調べてみる その3

はじめに

続きです。

今回はサニタイズせずに値を渡す方法、CSRF (XSRF) などを追いかけてみます。

Trusting safe values

以前Templateを使ってHTMLタグを挿入する場合、自動でサニタイズされることを学びました。

では信頼したHTMLなどの値を、サニタイズせずにページに反映するにはどうすべきか。

DomSanitizerのbypassSecurityTrustHtmlやbypassSecurityTrustUrlを使用します。

  • bypassSecurityTrustHtml
  • bypassSecurityTrustScript
  • bypassSecurityTrustStyle
  • bypassSecurityTrustUrl
  • bypassSecurityTrustResourceUrl

bypass-security.component.ts

import { Component, OnInit } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser';

@Component({
  selector: 'app-bypass-security',
  templateUrl: './bypass-security.component.html',
  styleUrls: ['./bypass-security.component.css']
})
export class BypassSecurityComponent implements OnInit {
  // 元の文字列. そのままTemplateに渡すとサニタイズされる.
  private dangerousHtml: string;
  private dangerousUrl: string; 

  // 信頼された値としてサニタイズされずに表示される.
  private trustedHtml: SafeHtml;
  private trustedUrl: SafeUrl;

  constructor(private sanitizer: DomSanitizer) { }

  ngOnInit() {
    this.dangerousHtml = '< button onclick="alert(0)">Click me!< /button>';
    this.dangerousUrl = 'javascript:alert("Hi there")';
    
    this.trustedHtml = this.sanitizer.bypassSecurityTrustHtml(this.dangerousHtml);
    this.trustedUrl = this.sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);
  }
}

bypass-security.component.html

< h4>An untrusted HTML:< /h4>
< iframe [srcdoc]='dangerousHtml'>< /iframe>
< h4>An trusted HTML:< /h4>
< iframe [srcdoc]='trustedHtml'>< /iframe>
< h4>An untrusted URL:< /h4>
< p>< a [href]="dangerousUrl">Click me< /a>< /p>
< h4>A trusted URL:< /h4>
< p>< a [href]="trustedUrl">Click me< /a>< /p>

これを実行すると以下のようになります。

f:id:mslGt:20170727071523j:plain

trustedHtml、trustedUrlの値がサニタイズされずに表示されているのが確認できます。

{{trustedHtml}}

さて、以前確認していたように、 < p>{{trustedHtml}}< /p> とするとどうなるでしょうか。

bypass-security.component.html

〜省略〜
< p>{{trustedHtml}}< /p>

結果は下記のようなメッセージが表示されます。

SafeValue must use [property]=binding: < button onclick="alert(0)">Click me!< /button> (see http://g.co/ng/security#xss)

つまり、{{trustedHtml}}として表示した場合は値を信頼している・していないに関係なくHTMLが反映されず、
文字列でHTMLのソースを渡して表示したい場合は、iframeなどを使って信頼した値として渡してね、ということのようです。

HTTP-level vulnerabilities

さて、ここからはサーバー側のお話となります。

当然サーバー側の対応はAngularではなく(今回は)Spring bootでの対応となるわけですが、
Cross-site request forgery (CSRF or XSRF) と Cross-site script inclusion (XSSI) についてはサーバー側の処理を補助する仕組みがあるとのことです。

エラーページ

完全に話がずれてしまいますが、引っかかったので書き残しておきます。

前回のCSPの話の中で、「WebSecurityConfig」というクラスを作ってURLごとに閲覧権限を付与していましたが、 例えば必要な権限を持たない状態でページにアクセスすると、Spring Securityがデフォルトで提供する(と思う)エラーページが表示されます。

これを自分で用意する、という場合についてです。

WebSecurityConfig.java

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user1")
                .password("1234")
                .roles("USER");
        auth
                .inMemoryAuthentication()
                .withUser("user2")
                .password("1234")
                .roles("ADMIN");
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 
                .and().formLogin()
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");
    }
}
  • 上記ではLogin画面はデフォルトのものが表示されますが、「formLogin()」のあとに「.loginPage(“/login”)」のようにログインページのパスを指定することで、
    (ルーティングはControllerクラスで実行)自分で用意したログインフォームを表示できます。
  • 上記では未ログイン時はまずログインフォームが表示され、ログイン後にAdmin権限を持っていない場合(つまりuser1でログインした場合)は「accessDeniedPage()」で指定したページが表示されます。

失敗1: ログインフォームを表示していない

WebSecurityConfig.java

〜省略〜
@Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 

                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");
    }
  }

上記のように、ログインフォームを表示する「.and().formLogin()」を入れずにaccessDeniedPageを指定したところ、
デフォルトのエラーページしか表示されませんでしたorz (未ログイン状態でアクセスして確認)

実際の使用ではまずログインさせるであろう、ということで、
特に問題は無いのですが、ちゃんとドキュメント等を読まずに適当にやると失敗する、という例でした。

失敗2: AccessDeniedPageの表示にAngularのルーティングを使おうとした

WebSecurityConfig.java

〜省略〜
@Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 
                .and().formLogin()
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied");
    }
  }

以前トライしたように、アクセス拒否のエラーでもAngularのルーティング機能を使ってページを表示しようとしたところ、
Admin権限でないと見られないはずの「/menulist」のページまで一般ユーザー権限のユーザーが閲覧できるようになってしまいましたorz

Spring boot側で「/accessdenied」に切り替える前に、Angularで「/menulist」を表示してしまうため、
ということなのでしょうか。

まぁそもそもエラーページは通常起こり得ない異常が発生したときに表示するものだと考えれば、
通常通り処理しようとするな、ということでもありますorz

ログインやログインフォームなどについては別途詳しく追いかけてみたいと思います。

Cross-site request forgery (CSRF or XSRF)

さて、元の話題に戻ります。

CSRFの説明は安全なウェブサイトの作り方の1.6などを参照していただけたらと思いますが、
簡単にまとめると

ユーザーが ①のページ を開いてログインを行い、ログインした状態のまま別のタブなどで ②罠の仕掛けられたページ を開いた場合に、
②のページから①のページに対し、ログイン済みの情報を利用して不正なリクエストを送ることができてしまう、という問題です。

この対策として、以下のような方法があります。(防ぎたいリクエストの種類にもよりますが)

  1. サーバー側とクライアント側でセッションID、乱数などのトークンを共有・確認する
  2. ユーザーに再ログインを求める
  3. CAPTCHA機能を利用する
  4. Referer情報から正しいURLから遷移したかを確認する

2、3はパスワードなどの変更や入出金などを行う時に見かけるように思います。

で、今回は1について追いかけてみることにします。

Angular

下記によると、AngularのHttpClient(後述しますが、正確にはHttpClientXsrfModule)にはCSRF(XSRF)の対策としてトークンを発行する機能があります。

クッキーの値は同じドメインのコードからしか読めないことから、そのリクエストが正しくユーザーからのものであり、
攻撃者からではないことが確認できます。

とのことです。

確認してみましょう。

Postリクエストを送信してみる(失敗)

ということで、Postリクエストを送信してみてどのようなHTTPヘッダにデータが渡されているかを見てみることにします。

まずはAngular側から。

post-page.component.ts

import { Component, OnInit } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'app-post-page',
  templateUrl: './post-page.component.html',
  styleUrls: ['./post-page.component.css']
})
export class PostPageComponent implements OnInit {

  constructor(private http_: Http) { }

  ngOnInit() {
    this.http_.post("/postsample", {id: 0, createdDate: "2017年6月24日", title: "ニュースだよ", article: "中身です"})
      .subscribe();
  }
}
  • 実際にはせっかく作成したinterface(top-news.ts)を利用したり、ボタン操作によって送信を行いたいところですが、
    今回は確認作業の簡略化のためngOnInitで実行しています。
  • Http.postの戻り値は「Observable」であり、実行には「subscribe」が必要です。

あとは任意のページからpost-page.componentが表示・実行されるようにしておけばOKです。

次にSpring bootです。
RestControllerクラスにPostメソッド用の処理を追加します。

import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;

@RestController
public class CrudSampleController {
    
    〜省略〜

    @PostMapping("/postsample")
    public TopNews postsample(@RequestBody TopNews news){
        System.out.println(news.article);
        return news;
    }
}
  • こちら側も今回は確認のため、特に何も処理を行わずに返しています。

さてこれで準備はOKです。
確認してみましょう!!

404エラーになりましたorz

Postメソッドの送信先が見つからない原因

ズバリSpring SecurityのCSRF対策のための機能です/(^o^)\

そのため、下記のようにCSRF対策を無効にすると正しくアクセスできます。

WebSecurityConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

〜省略〜

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN")
                .and().formLogin().loginPage("/login")
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");


       // CSRF対策を無効にする.
       http
                .csrf()
                .disable();

    }
}

これで不正アクセスしようとする攻撃者の気持ちが体験できたわけですね/(^o^)\

…というところでここからが本題です。

Angularでトークンを発行する

HttpClientXsrfModule を使って、CSRF対策のためのトークンを発行できます。

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
import { HttpClientXsrfModule } from '@angular/common/http'

import { AppComponent } from './app.component';
import { PostPageComponent } from './post-page/post-page.component';

@NgModule({
  declarations: [
    AppComponent,
    PostPageComponent
  ],
  imports: [
    BrowserModule,
    HttpModule,
    
    HttpClientXsrfModule.withOptions({
      cookieName: 'My-Xsrf-Cookie',
      headerName: 'My-Xsrf-Header',
    }),
    
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

この後後述のSpring boot側でAngularのトークンを使う設定を行うと、
Postリクエスト送信時のRequestHeaderで「XSRF-TOKEN」(Cookie)、「X-XSRF-TOKEN」というトークンが付与されます。

ここで一点注意があります。

HttpClient - Angular などでは「HttpClientXsrfModule.withConfig」としてcookieNameなどを設定していますが、
「withOptions」でないとエラーになります。

仕様変更があったのでしょうか。
HttpClientXsrfModule - Angularでは「withOptions」になっていますね。

ドキュメントの修正依頼ってプルリクになるのでしょうか。。。?

Spring bootでAngularのトークンを使うようにする

Spring bootでAngularのトークンを使用し、Postリクエストを有効にします。

WebSecurityConfig

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user1")
                .password("1234")
                .roles("USER");
        auth
                .inMemoryAuthentication()
                .withUser("user2")
                .password("1234")
                .roles("ADMIN");
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        
        http
                .httpBasic().and()
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and().formLogin()
                .and().csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
                
    }
}
  • トークンの指定は「csrf()」以降です。
  • Angularのトークンを使用するためには、CookieのHttpOnlyはFalseにしておく必要があるようです。
    「.csrfTokenRepository(new CookieCsrfTokenRepository)」のようにすると403エラーが発生しました。
  • 上記ではPostリクエストを送信する前にログインが必要で、「.anyRequest().authenticated()」を、
    「.anyRequest().permitAll()」にすればいけるのでは?と思いましたが、403エラーが発生しましたorz

おわりに

CSRF対策についてはとりあえずPostリクエストを送信できるようになりましたが、
現状だと全てのページでログインを行うか、CSRF対策を無効にする必要が出てきてしまいます。

次回は特定のページでのみCSRF対策を有効にし、ログインしていなくてもPost送信ができるようにする方法を調べてみます。

また、Angularのドキュメントの最後の項目、Cross-site script inclusion (XSSI)についても追いかけてみます。

参照

BypassSecurity

Spring Security

CSRF (XSRF)