Plan/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/RequestHandler.java

258 lines
11 KiB
Java

/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.webserver;
import com.djrapitops.plan.delivery.domain.auth.User;
import com.djrapitops.plan.delivery.web.resolver.Response;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.web.resolver.request.URIPath;
import com.djrapitops.plan.delivery.web.resolver.request.URIQuery;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.delivery.webserver.auth.*;
import com.djrapitops.plan.exceptions.WebUserAuthException;
import com.djrapitops.plan.settings.config.PlanConfig;
import com.djrapitops.plan.settings.config.paths.PluginSettings;
import com.djrapitops.plan.settings.config.paths.WebserverSettings;
import com.djrapitops.plan.settings.locale.Locale;
import com.djrapitops.plan.settings.locale.lang.PluginLang;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.utilities.logging.ErrorContext;
import com.djrapitops.plan.utilities.logging.ErrorLogger;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import net.playeranalytics.plugin.server.PluginLogger;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.TextStringBuilder;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* HttpHandler for WebServer request management.
*
* @author AuroraLS3
*/
@Singleton
public class RequestHandler implements HttpHandler {
private final Locale locale;
private final PlanConfig config;
private final DBSystem dbSystem;
private final Addresses addresses;
private final ResponseResolver responseResolver;
private final ResponseFactory responseFactory;
private final PluginLogger logger;
private final ErrorLogger errorLogger;
private final ActiveCookieStore activeCookieStore;
private final PassBruteForceGuard bruteForceGuard;
private List<String> ipWhitelist = null;
private final AtomicBoolean warnedAboutXForwardedSecurityIssue = new AtomicBoolean(false);
@Inject
RequestHandler(
Locale locale,
PlanConfig config,
DBSystem dbSystem,
Addresses addresses,
ResponseResolver responseResolver,
ResponseFactory responseFactory,
ActiveCookieStore activeCookieStore,
PluginLogger logger,
ErrorLogger errorLogger
) {
this.locale = locale;
this.config = config;
this.dbSystem = dbSystem;
this.addresses = addresses;
this.responseResolver = responseResolver;
this.responseFactory = responseFactory;
this.activeCookieStore = activeCookieStore;
this.logger = logger;
this.errorLogger = errorLogger;
bruteForceGuard = new PassBruteForceGuard();
}
@Override
public void handle(HttpExchange exchange) {
try {
Response response = getResponse(exchange);
response.getHeaders().putIfAbsent("Access-Control-Allow-Origin", config.get(WebserverSettings.CORS_ALLOW_ORIGIN));
response.getHeaders().putIfAbsent("Access-Control-Allow-Methods", "GET, OPTIONS");
response.getHeaders().putIfAbsent("Access-Control-Allow-Credentials", "true");
response.getHeaders().putIfAbsent("X-Robots-Tag", "noindex, nofollow");
ResponseSender sender = new ResponseSender(addresses, exchange, response);
sender.send();
} catch (Exception e) {
if (config.isTrue(PluginSettings.DEV_MODE)) {
logger.warn("THIS ERROR IS ONLY LOGGED IN DEV MODE:");
errorLogger.warn(e, ErrorContext.builder()
.whatToDo("THIS ERROR IS ONLY LOGGED IN DEV MODE")
.related(exchange.getRequestMethod(), exchange.getRemoteAddress(), exchange.getRequestHeaders(), exchange.getResponseHeaders(), exchange.getRequestURI())
.build());
}
} finally {
exchange.close();
}
}
public Response getResponse(HttpExchange exchange) {
if (ipWhitelist == null) {
ipWhitelist = config.isTrue(WebserverSettings.IP_WHITELIST)
? config.get(WebserverSettings.WHITELIST)
: Collections.emptyList();
}
String accessor = getAccessorAddress(exchange);
Request request = null;
Response response;
try {
request = buildRequest(exchange);
if (bruteForceGuard.shouldPreventRequest(accessor)) {
response = responseFactory.failedLoginAttempts403();
} else if (!ipWhitelist.isEmpty() && !ipWhitelist.contains(accessor)) {
response = responseFactory.ipWhitelist403(accessor);
logger.info(locale.getString(PluginLang.WEB_SERVER_NOTIFY_IP_WHITELIST_BLOCK, accessor, exchange.getRequestURI().toString()));
} else {
response = responseResolver.getResponse(request);
}
} catch (WebUserAuthException thrownByAuthentication) {
FailReason failReason = thrownByAuthentication.getFailReason();
if (failReason == FailReason.USER_PASS_MISMATCH) {
bruteForceGuard.increaseAttemptCountOnFailedLogin(accessor);
response = responseFactory.badRequest(failReason.getReason(), "/auth/login");
} else {
String from = exchange.getRequestURI().toASCIIString();
String directTo = StringUtils.startsWithAny(from, "/auth/", "/login") ? "/login" : "/login?from=." + from;
response = Response.builder()
.redirectTo(directTo)
.setHeader("Set-Cookie", "auth=expired; Path=/; Max-Age=1; SameSite=Lax; Secure;")
.build();
}
}
if (bruteForceGuard.shouldPreventRequest(accessor)) {
response = responseFactory.failedLoginAttempts403();
}
if (response.getCode() != 401 // Not failed
&& response.getCode() != 403 // Not blocked
&& request != null && request.getUser().isPresent() // Logged in
) {
bruteForceGuard.resetAttemptCount(accessor);
}
return response;
}
private String getAccessorAddress(HttpExchange exchange) {
if (config.isTrue(WebserverSettings.IP_WHITELIST_X_FORWARDED)) {
String header = exchange.getRequestHeaders().getFirst("X-Forwarded-For");
if (header == null) {
warnAboutXForwardedForSecurityIssue();
} else {
return header;
}
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
private void warnAboutXForwardedForSecurityIssue() {
if (!warnedAboutXForwardedSecurityIssue.get()) {
logger.warn("Security Vulnerability due to misconfiguration: X-Forwarded-For header was not present in a request & '" +
WebserverSettings.IP_WHITELIST_X_FORWARDED.getPath() + "' is 'true'!");
logger.warn("This could mean non-reverse-proxy access is not blocked & someone can use IP Spoofing to bypass security!");
logger.warn("Make sure you can only access Plan panel from your reverse-proxy or disable this setting.");
}
warnedAboutXForwardedSecurityIssue.set(true);
}
private Request buildRequest(HttpExchange exchange) {
String requestMethod = exchange.getRequestMethod();
URIPath path = new URIPath(exchange.getRequestURI().getPath());
URIQuery query = new URIQuery(exchange.getRequestURI().getRawQuery());
byte[] requestBody = readRequestBody(exchange);
WebUser user = getWebUser(exchange);
Map<String, String> headers = getRequestHeaders(exchange);
return new Request(requestMethod, path, query, user, headers, requestBody);
}
private byte[] readRequestBody(HttpExchange exchange) {
try (ByteArrayOutputStream buf = new ByteArrayOutputStream(512)) {
int b;
while ((b = exchange.getRequestBody().read()) != -1) {
buf.write((byte) b);
}
return buf.toByteArray();
} catch (IOException ignored) {
// requestBody stays empty
return new byte[0];
}
}
private WebUser getWebUser(HttpExchange exchange) {
return getAuthentication(exchange.getRequestHeaders())
.map(Authentication::getUser) // Can throw WebUserAuthException
.map(User::toWebUser)
.orElse(null);
}
private Map<String, String> getRequestHeaders(HttpExchange exchange) {
Map<String, String> headers = new HashMap<>();
for (Map.Entry<String, List<String>> e : exchange.getRequestHeaders().entrySet()) {
List<String> value = e.getValue();
headers.put(e.getKey(), new TextStringBuilder().appendWithSeparators(value, ";").build());
}
return headers;
}
private Optional<Authentication> getAuthentication(Headers requestHeaders) {
if (config.isTrue(WebserverSettings.DISABLED_AUTHENTICATION)) {
return Optional.empty();
}
List<String> cookies = requestHeaders.get("Cookie");
if (cookies != null && !cookies.isEmpty()) {
for (String cookie : new TextStringBuilder().appendWithSeparators(cookies, ";").build().split(";")) {
String[] split = cookie.trim().split("=", 2);
String name = split[0];
String value = split[1];
if ("auth".equals(name)) {
return Optional.of(new CookieAuthentication(activeCookieStore, value));
}
}
}
List<String> authorization = requestHeaders.get("Authorization");
if (authorization == null || authorization.isEmpty()) return Optional.empty();
String authLine = authorization.get(0);
if (StringUtils.contains(authLine, "Basic ")) {
return Optional.of(new BasicAuthentication(StringUtils.split(authLine, ' ')[1], dbSystem.getDatabase()));
}
return Optional.empty();
}
public ResponseResolver getResponseResolver() {
return responseResolver;
}
}