All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.vertx.ext.web.handler.impl.CSRFHandlerImpl Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2024 Red Hat, Inc.
 *
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  and Apache License v2.0 which accompanies this distribution.
 *
 *  The Eclipse Public License is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  The Apache License v2.0 is available at
 *  http://www.opensource.org/licenses/apache2.0.php
 *
 *  You may elect to redistribute this code under either of these licenses.
 */
package io.vertx.ext.web.handler.impl;

import io.vertx.core.Vertx;
import io.vertx.core.VertxException;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.CookieSameSite;
import io.vertx.core.internal.logging.Logger;
import io.vertx.core.internal.logging.LoggerFactory;
import io.vertx.ext.auth.prng.VertxContextPRNG;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.Session;
import io.vertx.ext.web.handler.CSRFHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.impl.Origin;
import io.vertx.ext.web.impl.RoutingContextInternal;
import io.vertx.ext.web.impl.Signature;
import io.vertx.ext.web.impl.Utils;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

/**
 * @author Paulo Lopes
 */
public class CSRFHandlerImpl implements CSRFHandler {

  private static final Logger LOG = LoggerFactory.getLogger(CSRFHandlerImpl.class);

  private final VertxContextPRNG random;
  private final Signature signature;

  private boolean nagHttps;
  private String cookieName = DEFAULT_COOKIE_NAME;
  private String cookiePath = DEFAULT_COOKIE_PATH;
  private String headerName = DEFAULT_HEADER_NAME;
  private long timeout = SessionHandler.DEFAULT_SESSION_TIMEOUT;

  private Origin origin;
  private boolean httpOnly;
  private boolean cookieSecure;

  public CSRFHandlerImpl(final Vertx vertx, final String secret) {
    random = VertxContextPRNG.current(vertx);
    signature = new Signature(secret);
  }

  @Override
  public CSRFHandler setOrigin(String origin) {
    this.origin = Origin.parse(origin);
    return this;
  }

  @Override
  public CSRFHandler setCookieName(String cookieName) {
    this.cookieName = cookieName;
    return this;
  }

  @Override
  public CSRFHandler setCookiePath(String cookiePath) {
    this.cookiePath = cookiePath;
    return this;
  }

  @Override
  public CSRFHandler setCookieHttpOnly(boolean httpOnly) {
    this.httpOnly = httpOnly;
    return this;
  }

  @Override
  public CSRFHandler setCookieSecure(boolean secure) {
    this.cookieSecure = secure;
    return this;
  }

  @Override
  public CSRFHandler setHeaderName(String headerName) {
    this.headerName = headerName;
    return this;
  }

  @Override
  public CSRFHandler setTimeout(long timeout) {
    this.timeout = timeout;
    return this;
  }

  @Override
  public CSRFHandler setNagHttps(boolean nag) {
    this.nagHttps = nag;
    return this;
  }

  private String generateAndStoreToken(RoutingContext ctx) {
    byte[] salt = new byte[32];
    random.nextBytes(salt);

    String saltPlusToken = Utils.base64UrlEncode(salt) + "." + System.currentTimeMillis();

    final String token = signature.sign(saltPlusToken);

    Session session = ctx.session();
    if (session != null) {
      // storing will include the session id too. The reason is that if a session is upgraded
      // we don't want to allow the token to be valid anymore
      session.put(headerName, session.id() + "/" + token);
    }

    return token;
  }

  private String getTokenFromSession(RoutingContext ctx) {
    Session session = ctx.session();
    if (session == null) {
      return null;
    }
    // get the token from the session
    String sessionToken = session.get(headerName);
    if (sessionToken != null) {
      // attempt to parse the value
      int idx = sessionToken.indexOf('/');
      if (idx != -1 && session.id() != null && session.id().equals(sessionToken.substring(0, idx))) {
        return sessionToken.substring(idx + 1);
      }
    }
    // fail
    return null;
  }

  /**
   * Check if a string is null or empty (including containing only spaces)
   *
   * @param s Source string
   * @return TRUE if source string is null or empty (including containing only spaces)
   */
  private static boolean isBlank(String s) {
    return s == null || s.trim().isEmpty();
  }

  private static long parseLong(String s) {
    if (isBlank(s)) {
      return -1;
    }

    try {
      return Long.parseLong(s);
    } catch (NumberFormatException e) {
      LOG.trace("Invalid Token format", e);
      // fallback as the token is expired
      return -1;
    }
  }

  @Override
  public void handle(RoutingContext ctx) {

    // we need to keep state since we can be called again on reroute
    if (!((RoutingContextInternal) ctx).seenHandler(RoutingContextInternal.CSRF_HANDLER)) {
      ((RoutingContextInternal) ctx).visitHandler(RoutingContextInternal.CSRF_HANDLER);
    } else {
      ctx.next();
      return;
    }

    if (nagHttps && LOG.isTraceEnabled()) {
      String uri = ctx.request().absoluteURI();
      if (uri != null && !uri.startsWith("https:")) {
        LOG.trace("Using session cookies without https could make you susceptible to session hijacking: " + uri);
      }
    }

    // If we're being strict with the origin
    // ensure that they are always valid
    if (!Origin.check(origin, ctx)) {
      ctx.fail(403, new VertxException("Invalid Origin", true));
      return;
    }

    String methodName = ctx.request().method().name();
    switch (methodName) {
      case "HEAD":
      case "GET":
        handleSafeMethod(ctx);
        break;
      case "POST":
      case "PUT":
      case "DELETE":
      case "PATCH":
        handleUnsafeMethod(ctx);
        break;
      default:
        // ignore other methods
        ctx.next();
        break;
    }
  }

  private void handleSafeMethod(RoutingContext ctx) {
    boolean sendCookie = true;
    Session session = ctx.session();
    String token;
    if (session == null) {
      // if there's no session to store values, tokens are issued on every request
      token = generateAndStoreToken(ctx);
    } else {
      // Get the token from the session, this also considers the fact
      // that the token might be invalid as it was issued for a previous session id.
      // Session ids change on session upgrades (unauthenticated -> authenticated; role change; etc...)
      String sessionToken = getTokenFromSession(ctx);
      // When there's no token in the session, then we behave just like when there is no session.
      // Create a new token, but we also store it in the session for the next runs.
      if (sessionToken == null) {
        token = generateAndStoreToken(ctx);
      } else {
        String[] parts = sessionToken.split("\\.");
        final long ts = parseLong(parts[1]);

        if (ts == -1) {
          // fallback as the token is expired
          token = generateAndStoreToken(ctx);
        } else {
          if (!(System.currentTimeMillis() > ts + timeout)) {
            // We're still on the same session, and the token hasn't expired so it can be reused.
            token = sessionToken;
            // If a previous unsafe interaction succeeded, but the response didn't get to the client
            // (e.g. the response headers were sent but not the response body), the session token may be different.
            // In this case, we send it again to synchronize with the client.
            // Otherwise, it's not necessary to send it again.
            Cookie cookie = ctx.request().getCookie(cookieName);
            if (cookie != null && token.equals(cookie.getValue())) {
              sendCookie = false;
            }
          } else {
            // Fallback as the token is expired
            token = generateAndStoreToken(ctx);
          }
        }
      }
    }
    synchronizeWithClient(ctx, token, sendCookie);
    ctx.next();
  }

  private void handleUnsafeMethod(RoutingContext ctx) {
    /* Verifying CSRF token using "Double Submit Cookie" approach */
    final Cookie cookie = ctx.request().getCookie(cookieName);

    String header = ctx.request().getHeader(headerName);
    if (header == null) {
      // fallback to form attributes
      if (ctx.body().available()) {
        header = ctx.request().getFormAttribute(headerName);
      } else {
        ctx.fail(new VertxException("BodyHandler is required to process POST requests", true));
        return;
      }
    }

    // both the header and the cookie must be present, not null and not empty
    if (header == null || cookie == null || isBlank(header)) {
      ctx.fail(403, new IllegalArgumentException("Token provided via HTTP Header/Form is absent/empty"));
      return;
    }

    final String cookieValue = cookie.getValue();

    if (cookieValue == null || isBlank(cookieValue)) {
      ctx.fail(403, new IllegalArgumentException("Token provided via HTTP Header/Form is absent/empty"));
      return;
    }

    final byte[] headerBytes = header.getBytes(StandardCharsets.UTF_8);
    final byte[] cookieBytes = cookieValue.getBytes(StandardCharsets.UTF_8);

    //Verify that token from header and one from cookie are the same
    if (!MessageDigest.isEqual(headerBytes, cookieBytes)) {
      ctx.fail(403, new IllegalArgumentException("Token provided via HTTP Header and via Cookie are not equal"));
      return;
    }

    final Session session = ctx.session();

    if (session != null) {
      // get the token from the session
      String sessionToken = session.get(headerName);
      if (sessionToken != null) {
        // attempt to parse the value
        int idx = sessionToken.indexOf('/');
        if (idx != -1 && session.id() != null && session.id().equals(sessionToken.substring(0, idx))) {
          String challenge = sessionToken.substring(idx + 1);
          // the challenge must match the user-agent input
          if (!MessageDigest.isEqual(challenge.getBytes(StandardCharsets.UTF_8), headerBytes)) {
            ctx.fail(403, new IllegalArgumentException("Token has been used or is outdated"));
            return;
          }
        } else {
          ctx.fail(403, new IllegalArgumentException("Token has been issued for a different session"));
          return;
        }
      } else {
        ctx.fail(403, new IllegalArgumentException("No Token has been added to the session"));
        return;
      }
    }

    if (!signature.verify(header)) {
      ctx.fail(403, new IllegalArgumentException("Token signature does not match"));
      return;
    }

    // if the token has expired remove the token from the session so that a new one can be acquired by a fresh GET
    // provided the user is authenticated.
    // We cannot simply remove it before these checks as this will invalidate the token even if the response is never
    // written, requiring the user to GET another token even though the previous was valid
    String[] tokens = header.split("\\.");
    if (tokens.length != 3) {
      if (session != null) {
        session.remove(headerName);
      }
      ctx.fail(403);
      return;
    }

    final long ts = parseLong(tokens[1]);

    if (ts == -1) {
      if (session != null) {
        session.remove(headerName);
      }
      ctx.fail(403);
      return;
    }

    // validate validity
    if (System.currentTimeMillis() > ts + timeout) {
      if (session != null) {
        session.remove(headerName);
      }
      ctx.fail(403, new IllegalArgumentException("CSRF validity expired"));
      return;
    }

    // The token matches, so refresh it to avoid replay attacks
    String token = generateAndStoreToken(ctx);
    synchronizeWithClient(ctx, token, true);
    ctx.next();
  }

  private void synchronizeWithClient(RoutingContext ctx, String token, boolean cookie) {
    if (cookie) {
      ctx.response()
        .addCookie(
          Cookie.cookie(cookieName, token)
            .setPath(cookiePath)
            .setHttpOnly(httpOnly)
            .setSecure(cookieSecure)
            // it's not an option to change the same site policy
            .setSameSite(CookieSameSite.STRICT));
    }
    // Put the token in the context for users who prefer to render the token directly on the HTML
    ctx.put(headerName, token);
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy