Sdílení session mezi protokoly HTTP a HTTPS

Je možné zajistit bezpečné sdílení HTTP session mezi oběma protokoly? Z dostupné dokumentace se dozvídáme, že nikoliv. Tento článek se zabývá možným řešením, které za jistých podmínek umožňuje bezpečně sdílet společnou session. Důvod proč se tímto problémem zabývat je jednoduchý - SSL šifrování je výpočetně nákladná věc (viz. např. Performance analysis of Secure HTTP Protocol). Proto je možná vhodné používat HTTPS pouze tam, kde je k tomu důvod (tedy např. uživatel pracuje s některými důvěrnými daty). Jistě se shodneme na tom, že na řadě webových aplikací je takto důvěrných míst pouze pár a zbytek bychom mohli hnát klidně přes protokol HTTP, čímž odlehčíme svému webovému serveru. Jenže tady narážíme na zásadní problém - nemůžeme nechráněným protokolem vyzradit identifikátor session (a naopak nesmíme akceptovat session, která vznikla přes protokol HTTP). Má tedy tato situace řešení, nebo nemá, jak se dočteme v řadě publikací?

Kolega Martin Veska ze společnosti FG Forrest přišel s návrhem řešení, které by umožňovalo bezpečně "vyzradit" identifikátor session, aniž bychom se vystavili riziku záměny autorizovaného uživatele. Jako každé jiné řešení má i toto své mínus, ale o tom až později v článku.

###

Sdílení session mezi oběma protokoly je problémová věc. Lépe řečeno - bezpečnostní díra. Typicky totiž po autentizaci uživatele nahrajete informace o něm (často i jeho oprávnění do session) a při dalším dotazu už pracujete s těmito informacemi (po prvotní autentizaci už uživateli "věříte"). Dotazy jsou mezi sebou provázány identifikátorem, který je posílaný prohlížečem uživatele (JSESSIONID), a který zajistí, že při dalším požadavku vám web server poskytne "tu jeho" serverovou session. Identifikátor je buď posílán jako cookie (obvykle) nebo v případě, že uživatel má zakázané cookies v URL.

Slabé místo je právě ve fázi předávání identifikátoru od uživatele na server a zpět. V případě, že tento identifikátor putuje nešifrovaným kanálem (HTTP), existuje teoretická šance, že jej může někdo odposlechnout a vydávat se za uživatele, jehož komunikaci odposlechl (prostě vám pošle stejný identifikátor ze svého počítače a vy na straně serveru nemáte šanci jak rozeznat, že se jedná o podvrh - opomineme-li problémové ověřování IP klienta). Proto se všude uvádí, že jediná bezpečná komunikace mezi serverem a uživatelem je 100% se držet šifrovaného spojení (tedy HTTPS).

Dokladem je např. úryvek z dokumentace Acegi Security:

"An important issue in considering transport security is that of session hijacking. Your web container manages a HttpSession by reference to a jsessionid that is sent to user agents either via a cookie or URL rewriting. If the jsessionid is ever sent over HTTP, there is a possibility that session identifier can be intercepted and used to impersonate the user after they complete the authentication process. This is because most web containers maintain the same session identifier for a given user, even after they switch from HTTP to HTTPS pages. If session hijacking is considered too significant a risk for your particular application, the only option is to use HTTPS for every request. This means the jsessionid is never sent across an insecure channel. You will need to ensure your web.xml -defined points to a HTTPS location, and the application never directs the user to a HTTP location. Acegi Security provides a solution to assist with the latter."

Ke ztrátě důvěryhodnosti navíc může dojít i na první pohled ne zcela viditelným způsobem. V případě, že vaše aplikace poskytuje alespoň jediný servlet dostupný protokolem HTTP, který během své činnosti nastartuje serverovou session (request.getSession(true)) - webový server tuto session vytvoří, přidělí ji identifikátor a ten odešle v odpovědi klientovi nešifrovaným kanálem jako cookie JSESSIONID. Přestože by uživatel hned dalším dotazem mířil na vaši aplikaci již přes protokol HTTPS, odešle spolu s requestem také již vytvořené JSESSIONID (jelikož se jedná o "nesecure" cookie prohlížeč ji může poslat jak kanálem HTTP, tak i HTTPS). Webový server ale tento identifikátor již akceptuje a při požadavku na session, již žádnou novou nevytváří, ale poskytne už tu vytvořenou - tzn. v takovémto případě uživatel sdílí session jak pro přístup přes HTTP tak i přes HTTPS - ale rozhodně není v bezpečí.

Jiný problém nastává v opačném případě, kdy uživatel jako první přistoupí na váš servlet přes HTTPS kanál. V takovém případě opět webový server při požadavku na session tuto session vytvoří, přidělí identifikátor, ale identifikátor posléze pošle klientovi ve formě tzv. "secure cookie". To znamená, že webový prohlížeč tuto cookie nesmí za žádných okolností poslat zpět serveru nešifrovaným (HTTP) kanálem. Mnohé potom překvapí, že když uživatel v dalším požadavku přistoupí opět na naši aplikaci tentokrát přes HTTP, webový server nám vytvoří úplně novou session - a tudíž nevidíme uživatelova data, které jsme si uložili v předchozím volání. To je způsobeno tím, že prohlížeč správně neodeslal identifikátor session, uložený v secure cookie nešifrovaným kanálem. Tentokrát jsme v bezpečí - ale aplikace nám nefunguje, tak jak bychom si představovali.

Řešení problému

K vyřešení tohoto problému postačují dvě jednoduché věci. Ty musíme ovšem provádět na straně serveru před zpracováním jakéhokoliv requestu (v našem případě jsme to vyřešili nasazením servletového filtru). Jeho kód uvádím níže:


package com.fg.user.web.filter;
import org.acegisecurity.providers.encoding.Md5PasswordEncoder;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
/**
 * Filter ensures that:
 *

 * 1) http and https shares the same session id
 * - when first access via https and and no JSESSIONID is sent it stores JSESSIONID cookie
 * as nonsecured into the request
 * 2) when on secure channel ensures that supplied JSESSIONID cookie is not spurious
 * - when first access via https it generates a new cookie SECURED_TOKEN that contains MD5 hash
 * of JSESSIONID and some secret salt - this cookie is then set as secured into the response
 * - every other access via https checks whether SECURED_TOKEN is set and that it corresponds
 * with JSESSIONID (and so that has not been stolen) - otherwise 403 will be returned
 *

 * Filter will provide secure access to protected actions via SSL ensuring that the session belongs
 * only to this client. But this means, that all important actions must be located at HTTPS (for sure
 * login action).
 *
 * Tradeoffs:
 * - clients that has not cookies allowed will receive HTTP 403 from the second SSL call on
 *
 * @author Martin Veska, Jan Novotný
 */
public class SharedSessionChannelFilter extends OncePerRequestFilter {
	public static final String SESSION_COOKIE_NAME = "JSESSIONID";
	public static final String NONSECURED_SESSION_COOKIE_SET = "NONSECURED_SESSION_COOKIE_SET";
	public static final String SECURE_TOKEN_PROVIDED = "SECURE_TOKEN_PROVIDED";
	public static final String SECURE_TOKEN_COOKIE_NAME = "SECURE_TOKEN";
	private static Log log = LogFactory.getLog(SharedSessionChannelFilter.class);
	private String salt;
	private String serverPath;
	/**
	 * Returns salt used for generating unique secure token for session.
	 *
	 * @return
	 */
	public String getSalt() {
		return salt != null ? salt : String.valueOf(System.currentTimeMillis());
	}
	/*
	 * Sets salt used for generating unique secure token for session.
	 */
	public void setSalt(String salt) {
		this.salt = salt;
	}
	/**
	 * Contains path for the generated secure token cookie.
	 * @return
	 */
	public String getServerPath() {
		return serverPath;
	}
	/**
	 * Contains path for the generated secure token cookie.
	 * @param serverPath
	 */
	public void setServerPath(String serverPath) {
		this.serverPath = serverPath;
	}
	/**
	 * Same contract as for doFilter, but guaranteed to be
	 * just invoked once per request. Provides HttpServletRequest and
	 * HttpServletResponse arguments instead of the default ServletRequest
	 * and ServletResponse ones.
	 */
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
									FilterChain filterChain) throws ServletException, IOException {
		if(request.isSecure()) {
			HttpSession session = request.getSession(true);
			Boolean nonSecuredSessionCookieSet = (Boolean)session.getAttribute(NONSECURED_SESSION_COOKIE_SET);
			String clientSecureToken = null;
			//ensures that nonsecured cookie is always the same as secured one
			Cookie[] cookies = request.getCookies();
			if (cookies != null) {
				for(Cookie cookie : cookies) {
					if(cookie.getName().equals(SESSION_COOKIE_NAME)) {
						//we copy nonsecured session cookie only once
						if(nonSecuredSessionCookieSet == null) {
							if(log.isDebugEnabled()) {
								log.debug("Secured session cookie found ... generating nonsecured one.");
							}
							Cookie nonSecureCookie = new Cookie(SESSION_COOKIE_NAME, cookie.getValue());
							nonSecureCookie.setMaxAge(-1);
							if (serverPath != null) nonSecureCookie.setPath(serverPath);
							nonSecureCookie.setSecure(false);
							response.addCookie(nonSecureCookie);
							session.setAttribute(NONSECURED_SESSION_COOKIE_SET, Boolean.TRUE);
						}
					}
					//we accept secured secured client tokens
					if(cookie.getName().equals(SECURE_TOKEN_COOKIE_NAME)) {
						clientSecureToken = cookie.getValue();
					}
				}
			}
			//ensure that nonsecured cookie is not spurious
			String serverSecureToken = (String)session.getAttribute(SECURE_TOKEN_PROVIDED);
			if(serverSecureToken != null) {
				if(log.isDebugEnabled()) {
					log.debug("Secured token was provided for this session, verifying validity.");
				}
				if(clientSecureToken == null || !clientSecureToken.equals(serverSecureToken)) {
					//unauthorized access!!
					//secured token for this session was generated but user client did not
					//provided secured cookie with this token
					if(log.isErrorEnabled()) {
						log.error("Client has provided null or wrong secured token! Expecting: " + serverSecureToken + ", client provided: " + clientSecureToken);
					}
					response.sendError(HttpServletResponse.SC_FORBIDDEN);
				}
				else {
					//client is authorized
					if(log.isDebugEnabled()) {
						log.debug("Access allowed, secured token verified.");
					}
					filterChain.doFilter(request, response);
				}
			}
			else {
				if(log.isDebugEnabled()) {
					log.debug("Access allowed - secured token has not been gerated for this session yet. Generating new one.");
				}
				serverSecureToken = getSecuredToken(session, getSalt());
				Cookie securedCookie = new Cookie(SECURE_TOKEN_COOKIE_NAME, serverSecureToken);
				securedCookie.setSecure(true);
				if (getServerPath() != null) securedCookie.setPath(getServerPath());
				securedCookie.setMaxAge(-1);
				response.addCookie(securedCookie);
				session.setAttribute(SECURE_TOKEN_PROVIDED, serverSecureToken);
				//client is authorized
				filterChain.doFilter(request, response);
			}
		} else {
			//non secured channel is always allowed
			filterChain.doFilter(request, response);
		}
	}
	/**
	 * Returns MD5 hash of session id plus some salt. Should be considerably unique.
	 *
	 * @param session
	 * @param salt
	 * @return
	 */
	private String getSecuredToken(HttpSession session, String salt) {
		Md5PasswordEncoder encoder = new Md5PasswordEncoder();
		return encoder.encodePassword(session.getId(), salt);
	}
}

Konfigurace ve springu potom takto (puze výňatek z komplexnější Acegi konfigurace):


<bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
<property name="filterInvocationDefinitionSource">
		<value>
			CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
			PATTERN_TYPE_APACHE_ANT
			/**=channelProcessingFilter,sharedSessionChannelFilter
		</value>
	</property>
</bean>
<bean id="channelProcessingFilter" class="org.acegisecurity.securechannel.ChannelProcessingFilter">
<property name="channelDecisionManager">
		<ref bean="channelDecisionManager"/>
	</property>
<property name="filterInvocationDefinitionSource">
		<value>
			CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
			PATTERN_TYPE_APACHE_ANT
			/**/secure/**=REQUIRES_SECURE_CHANNEL
			/**=REQUIRES_INSECURE_CHANNEL
		</value>
	</property>
</bean>
<bean id="sharedSessionChannelFilter" class="com.fg.user.web.filter.SharedSessionChannelFilter">
<property name="salt" value="nejakaSuperTajnaCastHesla"/>
<property name="serverPath" value="/webRootContext"/>
</bean>

Zajištění spolehlivého sdílení session

První věcí je vyřešení neblahého stavu, kdy session vytvořená v HTTPS requestu není viditelná v následném HTTP požadavku. Toto je možné jednoduše vyřešit tím, že i v případě prvního přístupu přes HTTPS vynutíme odeslání JSESSIONID cookie jako ne secure.

Tím je sice problém vyřešen, ale otvíráme bránu k odposlechnutí této informace. Proto musíme bezpečnost zajistit nějak jinak.

Zajištění bezpečného přístupu přes HTTPS

Při prvním přístupu protokolem HTTPS, vytvoříme tzv. secure token - což je unikátní řetězec (vypočtený např. na základě JSESSIONID + nějakého dalšího modifikátoru) , který odešleme uživateli v odpovědi jako secure cookie. Tento token nám v podstatě nahrazuje původní důvěryhodnou JSESSIONID, kterou jsme byli nuceni vyzradit. Secure token nám prohlížeč odešle vždy, když uživatel bude přistupovat na naši aplikaci přes HTTPS a jen tehdy můžeme na straně serveru ověřit, že uživatel je skutečně stále ten stejný uživatel, kterého jsme přihlásili.

Z výše uvedeného tedy vyplývá následující základní pravidlo: jakékoli operace, u kterých si chceme být jisti, že je skutečně provádí uživatel, kterého jsme přihlásili (jako např. změna hesla, přihlášení, změna údajů uživatele, odeslání objednávky atp.), musíme provádět pouze přes protokol HTTPS - jelikož jen v něm je možné zkontrolovat secure token.

Při každém následujícím požadavku přes protokol HTTPS filtr zkontroluje zda spolu s JSESSIONID přišel také správný secure token, který byl pro tuto session na počátku vygenerován. Jakmile tento secure token nepřijde nebo se liší od vydaného tokenu pro konkrétní session, filtr nepovolí zpracování požadavku a vrátí HTTP Error 403 - Forbidden.

Z toho také vyplývá omezení, že uživatelé, kteří nemají povolené cookies nebudou moci s naší aplikací (konkrétně tedy s částí přístupnou pouze přes HTTPS) pracovat. Podobná logika není možná zajistit v případě URL rewritingu.

Závěrem

Výše uvedený článek má celkem dva cíle. Jednak se podělit s komunitou o naše myšlenky a druhak si ověřit, jestli přeci jen není možné najít způsob, kterým by bylo možné výše popsanou logiku obejít a přihlášení nějakým způsobem zneužít. Celou problematiku jsme analyzovali z různých stran a toto řešení nám připadá jako bezpečné. Jak se ovšem říká - "nikdy neříkej nikdy" a proto budeme vděčni za vaše názory, či myšlenky, jak by bylo možné popsané zabezpečení "prostřelit".