不正遷移対策
不正遷移対策とは?
何故ってブラウザ依存の機能だからです。
システム屋からするとこれらブラウザ依存の機能は使われると困ります。 何故って本来いるハズの画面Aから、サーバを通さずに画面Bにいる状態になるからですよ。
こんな状況を想定したシステム構築なんて無理だし、そもそも無意味です。 Webアプリにおいて動作を保証しているのは、あくまで『ドキュメント』の部分なんですよ。(HTMLが描画される範囲)
これは暗黙の了解だし、わざわざクライアントと認識すら合わせないレベルの話です。
もうコレ、テレビを指さして『アプリ起動しないぞっ』て言われてる様なものです(PC必要です) しかしそんな事をクライアントにストレートには言えません。
なので本質は『ブラウザ動作を保証してるのではなく、ドキュメント動作を保証している』の姿勢は崩さずに、
『とは言え、ブラウザ操作における最低限の管理はしますよ』的な、サービス向上に努める姿勢を見せるのが大事になります。 これでクライアントも『お、サービスしてくれるんだね』と思い、話が丸く収まります。 ではどうやって管理するか?
答えは、トークン管理を使います。 では何処で管理すか?
そりゃWeb通信の入口である『フィルタ』ですよね。
フィルタの作成
今回は『filter/CharacterEncodingFilter.java』を修正して不正遷移ロジックを作成します。
package filter;
import jakarta.faces.application.ViewExpiredException;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;
import java.util.UUID;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
private static final long serialVersionUID = 1L;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// リクエストもレスポンスもUTF-8にします
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
HttpServletRequest req = (HttpServletRequest) request;
HttpSession session = req.getSession();
if ("POST".equalsIgnoreCase(req.getMethod())) {
String requestToken = req.getParameter("checkToken");
String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
// トークン検証
if (sessionToken == null || !sessionToken.equals(requestToken)) {
// 不正遷移・二重送信!
throw new ViewExpiredException(null);
}
// トークンエラーなし
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
if (session.getAttribute("CHECK_TOKEN") == null) {
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
}
}
// 次の処理(FacesServletなど)にバトンを渡します
chain.doFilter(request, response);
}
}
当然『CHECK_TOKEN』の値は『null』なので、新たに『CHECK_TOKEN』をセッションで発行します。
② 画面Aを描画する際に『CHECK_TOKEN』の内容を『type=hidden』で保持します。
③ 画面Aから画面BへPOST遷移するボタンが押下されます。
④ まずは、画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑤ セッションは①で発行しているので『null』でもなく、②で保持した内容と同じなので検証は通過します。
⑥ 通過したら、新たに『CHECK_TOKEN』をセッションで発行し画面Bへ遷移します。 正常遷移の場合は①~⑥をループします。
当然『CHECK_TOKEN』の値は『null』なので、新たに『CHECK_TOKEN』をセッションで発行します。
② 画面Aを描画する際に『CHECK_TOKEN』の内容を『type=hidden』で保持します。
③ 画面Aから画面BへPOST遷移するボタンが押下されます。
④ まずは、画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑤ セッションは①で発行しているので『null』でもなく、②で保持した内容と同じなので検証は通過します。
⑥ 通過したら、新たに『CHECK_TOKEN』をセッションで発行し画面Bへ遷移します。
⑦ ヒストリーバックで画面Aに戻ります。
この時の『CHECK_TOKEN』は以下となっています。
ブラウザ側:①で発行されたセッションの値
サーバ側 :⑥で発行されたセッションの値
⑧ 画面Aから画面BへPOST遷移するボタンが押下されます。
⑨ 画面AのバッキングビーンにPOST遷移としてフィルタが発火します。
⑩ セッションは⑥で発行しているので『null』ではないが、双方の値が①⑥なので値に相違があります。
この場合を『不正遷移』とし、例外として『ViewExpiredException』を送出します。
※例外の処理については『エラーハンドリング』『web.xml』設定などで制御して下さい
画面にアクセスしたら『自画面』へPOST遷移してみて下さい。
何度遷移ボタンを押下しても、正常に自画面に遷移できます。
しかし、一度でもヒストリーバックすると、以降の『自画面』遷移をするとエラーとなります。 これが『不正遷移対策』です。
不正遷移対策(フィルターの罠)
色々と動作確認をされた方は画面に大量のエラーメッセージが表示されたと思います。 でもちょっと待ってください。
前回の章で『エラーハンドラ』クラスを作成された方ならお分かりの通り、なんと
『スローしてもエラーハンドラが発火しない』 発火しないので、例外をスローしてもエラー制御自体ができない状況です。
何故発火しないのでしょうか? 実はフィルター処理ってフレームワークの『管轄外』なんですよね。
PHPなんかを深く経験してる方だと『URL叩いた瞬間からPHPの管理内だろ』って思うかもですが、
そこは悲しきJAVAの『レイヤー構造』の宿命なのです。 色々な処理を色々なレイヤーが担当しています。
そしてJAVAソースとしてフィルターを記述しているため非常に嵌りやすいのですが、
・エラーハンドラ:JSF(FacesServlet)の管轄
よって制御を失った『ViewExpiredException』は画面にはけ口を求めて大量のメッセージを吐き出すのです。
また『AJAX』になるともっと厄介で、そもそも画面再描画をしないのがAJAXですので、エラーになっても見た目は何も変わりません。
でも内部的にはエラーになってるんですよ。 もうその後の挙動が怖くてたまりませんよね。 ではフィルターから多少強引ですが『エラーハンドラ』を発火させる様にフィルタを修正します。
package filter;
import java.io.IOException;
import jakarta.faces.FactoryFinder;
import jakarta.faces.application.ViewExpiredException;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.FacesContextFactory;
import jakarta.faces.lifecycle.Lifecycle;
import jakarta.faces.lifecycle.LifecycleFactory;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;
import java.util.UUID;
import exception.MyExceptionHandler;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
private static final long serialVersionUID = 1L;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// リクエストもレスポンスもUTF-8にします
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
// トークンチェック用フィルタ開始
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
HttpSession session = req.getSession();
if ("POST".equalsIgnoreCase(req.getMethod())) {
String requestToken = req.getParameter("checkToken");
String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
// トークン検証
if (sessionToken == null || !sessionToken.equals(requestToken)) {
// 不正遷移・二重送信!
ViewExpiredException exception = new ViewExpiredException("無効なトークンです。");
FacesContext context = getFacesContext(req, res);
MyExceptionHandler handler = new MyExceptionHandler(context.getExceptionHandler());
handler.processEvent(
new jakarta.faces.event.ExceptionQueuedEvent(
context,
new jakarta.faces.event.ExceptionQueuedEventContext(context, exception)
)
);
handler.handle();
}
// トークンエラーなし
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
if (session.getAttribute("CHECK_TOKEN") == null) {
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
}
}
// 次の処理(FacesServletなど)にバトンを渡します
chain.doFilter(request, response);
}
// FacesContext を取得・生成するヘルパー
private FacesContext getFacesContext(HttpServletRequest request, HttpServletResponse response) {
FacesContext context = FacesContext.getCurrentInstance();
if (context != null) {
return context;
}
FacesContextFactory contextFactory = (FacesContextFactory) FactoryFinder.getFactory(FactoryFinder.FACES_CONTEXT_FACTORY);
LifecycleFactory lifecycleFactory = (LifecycleFactory) FactoryFinder.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
Lifecycle lifecycle = lifecycleFactory.getLifecycle(LifecycleFactory.DEFAULT_LIFECYCLE);
return contextFactory.getFacesContext(request.getServletContext(), request, response, lifecycle);
}
}
② 作成した『getFacesContext』関数より『FacesContext型インスタンス』を取得する
③ エラーハンドラクラスを初期化します。
④ 初期化した③に①を渡す事で、例外のキューに①を格納した処理となります。
⑤ ここまでの処理で『JSF管轄のエラー情報』となったので、『エラーハンドラ.handle()』関数を呼び出します。
⑥ あとはエラーハンドラで好きなように制御しちゃいましょう。
※『エラーハンドリングクラスを作成する』参照
トークンタグの自動付与(ヘルパー)
この辺りの設定って特にチームで開発してると忘れがちなので、いきなりエラーになって『あれ、あれっ?』となったりしますよね。 そこで『<h:form ..』を記述したら自動的にトークンタグも描画する様にできたら便利です。
別にトークンチェックしない運用でも、トークンタグ自体は何の意味も持たないタグですから。
では『h:form..』タグに対応するヘルパー『renderkit/CustomFormRendererWrapper.java』を作成します。
package renderkit;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.faces.render.Renderer;
import jakarta.faces.render.RendererWrapper;
import java.io.IOException;
import java.util.Map;
@SuppressWarnings("rawtypes")
public class CustomFormRendererWrapper extends RendererWrapper {
private final Renderer wrapped;
@SuppressWarnings("deprecation")
public CustomFormRendererWrapper(Renderer wrapped) {
this.wrapped = wrapped;
}
public Renderer getWrapped() {
return wrapped;
}
@Override
public void encodeBegin(FacesContext context, UIComponent component) throws IOException {
super.encodeBegin(context, component);
// formタグの後に追記した内容を作成する
FacesContext facesContext = FacesContext.getCurrentInstance();
ExternalContext externalContext = facesContext.getExternalContext();
Map<String, Object> sessionMap = externalContext.getSessionMap();
String tokenTag
= "<input type='hidden' id='checkToken' name='checkToken' value='" + sessionMap.getOrDefault("CHECK_TOKEN", "") + "' />";
// タグを追記します
context.getResponseWriter().write(tokenTag);
}
@Override
public void encodeEnd(FacesContext context, UIComponent component) throws IOException {
super.encodeEnd(context, component);
}
}
その後任意の内容を作成し『context.getResponseWriter().write(任意の内容)』で追記できます。 次は『renderkit/CustomRenderKit.java』へ呼び出し元クラスを作成します。
package renderkit;
import jakarta.faces.render.RenderKit;
import jakarta.faces.render.RenderKitWrapper;
import jakarta.faces.render.Renderer;
public class CustomRenderKit extends RenderKitWrapper {
private final RenderKit wrapped;
@SuppressWarnings("deprecation")
public CustomRenderKit(RenderKit wrapped) {
this.wrapped = wrapped;
}
@Override
public RenderKit getWrapped() {
return wrapped;
}
@SuppressWarnings("rawtypes")
@Override
public Renderer getRenderer(String family, String rendererType) {
Renderer original = wrapped.getRenderer(family, rendererType);
if ("jakarta.faces.Form".equals(family) &&
"jakarta.faces.Form".equals(rendererType)) {
return new CustomFormRendererWrapper(original);
}
return original;
}
}
『Formタグ』を処理する場合は、本来『original』を返却します。
しかし『h:form』タグの場合に限り『CustomFormRendererWrapper』クラスの処理結果を返却します。 次はファクトリクラス『renderkit/CustomRenderKitFactory.java』を作成します。
package renderkit;
import java.util.Iterator;
import jakarta.faces.render.RenderKit;
import jakarta.faces.render.RenderKitFactory;
public class CustomRenderKitFactory extends RenderKitFactory {
private final RenderKitFactory wrapped;
public CustomRenderKitFactory(RenderKitFactory wrapped) {
super(wrapped);
this.wrapped = wrapped;
}
@Override
public RenderKit getRenderKit(jakarta.faces.context.FacesContext context, String renderKitId) {
RenderKit original = wrapped.getRenderKit(context, renderKitId);
return new CustomRenderKit(original);
}
@Override
public void addRenderKit(String renderKitId, RenderKit renderKit) {
wrapped.addRenderKit(renderKitId, renderKit);
}
@Override
public Iterator<String> getRenderKitIds() {
return null;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<faces-config
xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd"
version="4.0">
<factory>
<exception-handler-factory>exception.MyExceptionHandlerFactory</exception-handler-factory>
</factory>
<factory>
<render-kit-factory>renderkit.CustomRenderKitFactory</render-kit-factory>
</factory>
</faces-config>
同じ挙動となるハズです。
AJAXによる非同期通信の場合『token』の値は書き換わりません。
ただし『f:ajax execute="@form" render="@form"』とする事で書き換わります。
この場合は自動で『checkToken』タグを作成するのを諦めましょう。 ただし例えば以下の様に記述すると『form』タグ内の任意の属性の値を取得できます。
『component.getAttributes().get("addToken");』 上記は『h:form addToken="false"』の様に「.xhtml」内に記述すると『false』が取得できます。
つまり、バッキングビーン単位で動的にトークンタグの《表示|非表示》の制御が可能となります。
非表示にした場合は「.xhtml」に手作業でトークンタグを記述し、バッキングビーンで値を設定する必要があります。
値は当然以下セッションの値となります。
(String) getSessionMap().getOrDefault("CHECK_TOKEN", "") 「.xhtml」は以下の様に記述します。
<h:inputText type='hidden' id='checkToken' name='checkToken' value='#{sampleBean.checkToken}' />
<h:commandButton ~
<f:ajax execute="hoge checkToken" render="msg hoge checkToken" />
動的設定によるトークンチェック
そう『appinfo.properties』から動的にトークンチェック有無を制御します。
※『動的環境設定』参照 それでは以下のキーで制御してみます。
TOKEN_BLACK_LIST・・・ブランクの場合チェックなし。『*』の場合全画面チェック。『index|next』の様に複数選択可とする。
評価は「ホワイトリスト」⇒「ブラックリスト」の順番とします。
つまりどちらも『*』としたらトークンチェックはスキップされます。
またいくら「ブラックリスト」に設定しても『TOKEN_WHITE_LIST=』だと無意味です。([TOKEN_WHITE_LIST=*]とする事) 一番オーソドックスなのは以下設定ですね(全画面チェック)
TOKEN_BLACK_LIST=
package filter;
import java.io.IOException;
import jakarta.faces.FactoryFinder;
import jakarta.faces.application.ViewExpiredException;
import jakarta.faces.context.FacesContext;
import jakarta.faces.context.FacesContextFactory;
import jakarta.faces.lifecycle.Lifecycle;
import jakarta.faces.lifecycle.LifecycleFactory;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.Serializable;
import java.util.UUID;
import config.AppConfig;
import exception.MyExceptionHandler;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@WebFilter("/*")
public class CharacterEncodingFilter implements Filter, Serializable {
private static final long serialVersionUID = 1L;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// リクエストもレスポンスもUTF-8にします
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
// トークンチェック用フィルタ開始
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
// 対象画面名取得
String servletPath = req.getServletPath();
String pageName = servletPath.substring(servletPath.lastIndexOf('/') + 1);
HttpSession session = req.getSession();
if ("POST".equalsIgnoreCase(req.getMethod())) {
String requestToken = req.getParameter("checkToken");
String sessionToken = (String) session.getAttribute("CHECK_TOKEN");
// トークンチェックの有無を「appinfo」から確認
if (checkAppInfo(pageName)) {
// トークン検証
if (sessionToken == null || !sessionToken.equals(requestToken)) {
// 不正遷移・二重送信!
ViewExpiredException exception = new ViewExpiredException("無効なトークンです。");
FacesContext context = getFacesContext(req, res);
MyExceptionHandler handler = new MyExceptionHandler(context.getExceptionHandler());
handler.processEvent(
new jakarta.faces.event.ExceptionQueuedEvent(
context,
new jakarta.faces.event.ExceptionQueuedEventContext(context, exception)
)
);
handler.handle();
}
}
// トークンエラーなし
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
if (session.getAttribute("CHECK_TOKEN") == null) {
String newToken = UUID.randomUUID().toString();
session.setAttribute("CHECK_TOKEN", newToken);
}
}
// 次の処理(FacesServletなど)にバトンを渡します
chain.doFilter(request, response);
}
// FacesContext を取得・生成するヘルパー
private FacesContext getFacesContext(HttpServletRequest request, HttpServletResponse response) {
FacesContext context = FacesContext.getCurrentInstance();
if (context != null) {
return context;
}
FacesContextFactory contextFactory = (FacesContextFactory) FactoryFinder.getFactory(FactoryFinder.FACES_CONTEXT_FACTORY);
LifecycleFactory lifecycleFactory = (LifecycleFactory) FactoryFinder.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
Lifecycle lifecycle = lifecycleFactory.getLifecycle(LifecycleFactory.DEFAULT_LIFECYCLE);
return contextFactory.getFacesContext(request.getServletContext(), request, response, lifecycle);
}
// トークンチェックの有無を「appinfo」から確認
private boolean checkAppInfo(String pageName) {
if (pageName == null || pageName.trim().isEmpty()) {
return false;
}
String[] page = pageName.split("\\.");
String whiteList = AppConfig.get("TOKEN_WHITE_LIST", "");
String blackList = AppConfig.get("TOKEN_BLACK_LIST", "");
// TOKEN_WHITE_LIST確認
if (whiteList.trim().isEmpty()) {
return false;
}
// TOKEN_BLACK_LIST確認
if (blackList.trim().equals("*")) {
return false;
}
// TOKEN_BLACK_LIST分解
String[] blacks = blackList.split("\\|");
for (String black : blacks) {
if (black.equals(page[0])) {
return false;
}
}
// TOKEN_WHITE_LIST確認
if (whiteList.trim().equals("*")) {
return true;
}
// TOKEN_WHITE_LIST分解
String[] whites = whiteList.split("\\|");
for (String white : whites) {
if (white.equals(page[0])) {
return true;
}
}
return false;
}
}
② トークンチェックの有無を関数『checkAppInfo』で判定する。
③ ②の判定で《真》の場合は、トークンチェックを実施する
不正遷移処理って意外と重要です。
良く例に上がるケースに『2重送信問題』があります。
簡易な対策として、送信後リダイレクトする方法があります。
でもこれってヒストリーバックすれば『2重送信』できますよね?
こういった制御を適切に設定する事でシステムはより強固となります。 今後システムをグランドデザインする際には是非参考にしていただければ幸いです。




今回は『h:form』タグを拡張しましたが、基本は全てのタグで同じです。
ここでの条件を追加する事で、様々なタグの拡張を作成できます。『CustomRenderKit.java』の以下条件によって処理を振り分けています。
if ("jakarta.faces.Form".equals(family) && "jakarta.faces.Form".equals(rendererType))