Skip to content

Commit

Permalink
Merge branch 'master' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
abirembaut committed Jan 19, 2024
2 parents b01ed3c + 4a299cc commit c8e96ff
Show file tree
Hide file tree
Showing 7 changed files with 543 additions and 5 deletions.
1 change: 1 addition & 0 deletions bpm/bonita-web-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
implementation "org.springframework:spring-web:${Deps.springVersion}"
implementation "org.springframework:spring-webmvc:${Deps.springVersion}"
implementation "org.fedorahosted.tennera:jgettext:${Deps.jgettextVersion}"
implementation "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:${Deps.owaspHTMLSanitizerVersion}"

compileOnly "org.projectlombok:lombok:${Deps.lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${Deps.lombokVersion}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* Copyright (C) 2024 Bonitasoft S.A.
* Bonitasoft, 32 rue Gustave Eiffel - 38000 Grenoble
* This library is free software; you can redistribute it and/or modify it under the terms
* of the GNU Lesser General Public License as published by the Free Software Foundation
* version 2.1 of the License.
* This library 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 this
* program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth
* Floor, Boston, MA 02110-1301, USA.
**/
package org.bonitasoft.console.common.server.filter;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Stream;

import javax.servlet.FilterChain;
import javax.servlet.ReadListener;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import org.apache.commons.io.IOUtils;
import org.bonitasoft.console.common.server.preferences.properties.PropertiesFactory;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;

/**
* This class is used to filter malicious payload (e.g. XSS injection) by
* neutralizing the injected code.
*
* @author Vincent Hemery
*/
public class SanitizerFilter extends ExcludingPatternFilter {

/**
* Sanitizer to apply to values.
* Do not let TABLES and LINKS which can be mis-leading as phishing.
*/
private static final PolicyFactory sanitizer = Sanitizers.BLOCKS.and(Sanitizers.FORMATTING).and(Sanitizers.STYLES)
.and(Sanitizers.IMAGES);

/** The HTTP methods concerned by this filter. */
private static final String[] CONCERNED_METHODS = { "POST", "PUT", "PATCH" };

/** Json object mapper */
private ObjectMapper mapper = new ObjectMapper();

private boolean isEnabled = PropertiesFactory.getSecurityProperties().isSanitizerProtectionEnabled();

private List<String> attributesExcluded = PropertiesFactory.getSecurityProperties()
.getAttributeExcludedFromSanitizerProtection();

@Override
public void destroy() {
}

@Override
public String getDefaultExcludedPages() {
// excludes nothing for now
return "";
}

@Override
public void proceedWithFiltering(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
final HttpServletRequest req = (HttpServletRequest) request;
var method = req.getMethod();
if (!isSanitizerEnabled() || method == null
|| Stream.of(CONCERNED_METHODS).noneMatch(method::equalsIgnoreCase)) {
// no body to sanitize, just skip this filter
chain.doFilter(req, response);
return;
}
// get body of request as Json
var body = getJsonBody(req);
// sanitize body
final var sanitized = sanitize(body);
if (sanitized.isPresent()) {
// serialize the sanitized json node
byte[] saneBodyBytes = mapper.writeValueAsBytes(sanitized.get());

// wrap request with sanitized body for input stream
sanitized.get();
final var wrapper = new HttpServletRequestWrapper(req) {

private ServletInputStream inputStream = null;

@Override
public ServletInputStream getInputStream() throws IOException {
if (inputStream == null) {
final ByteArrayInputStream is = new ByteArrayInputStream(saneBodyBytes);
inputStream = new ServletInputStream() {

@Override
public int read() throws IOException {
return is.read();
}

@Override
public boolean isFinished() {
return is.available() == 0;
}

@Override
public boolean isReady() {
return !isFinished();
}

@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException("Unimplemented method 'setReadListener'");
}
};
}
return inputStream;
}

@Override
public int getContentLength() {
return saneBodyBytes.length;
}

@Override
public long getContentLengthLong() {
return saneBodyBytes.length;
}

};
chain.doFilter(wrapper, response);
} else {
chain.doFilter(req, response);
}
}

/**
* Sanitize the given JsonNode.
*
* @param node Json node to sanitize
* @return the sanitized Json node if it has effectively changed
*/
protected Optional<JsonNode> sanitize(JsonNode node) {
if (node == null) {
return Optional.empty();
} else if (node.isObject()) {
AtomicBoolean changed = new AtomicBoolean(false);
ObjectNode object = (ObjectNode) node;
object.fields().forEachRemaining(entry -> {
var key = entry.getKey();
var newKey = sanitizeValueAndPerformAction(key, s -> {
object.remove(key);
object.set(s, entry.getValue());
changed.set(true);
}).orElse(key);

if (getAttributesExcluded() == null || !getAttributesExcluded().contains(key)) {
var value = entry.getValue();
sanitize(value).ifPresent(v -> {
object.set(newKey, v);
changed.set(true);
});
}
});
return changed.get() ? Optional.of(object) : Optional.empty();
} else if (node.isArray()) {
AtomicBoolean changed = new AtomicBoolean(false);
ArrayNode array = (ArrayNode) node;
for (int i = 0; i < array.size(); i++) {
var value = array.get(i);
final int index = i;
sanitize(value).ifPresent(v -> {
array.set(index, v);
changed.set(true);
});
}
return changed.get() ? Optional.of(array) : Optional.empty();
} else if (node.isValueNode()) {
if (node.isBoolean() || node.isNumber() || node.isPojo()) {
// that's safe
return Optional.empty();
}
var changedValue = sanitizeValueAndPerformAction(node.textValue(), v -> {
});
return changedValue.map(TextNode::new);
}
return Optional.empty();
}

private JsonNode getJsonBody(HttpServletRequest request) throws ServletException {
if (request.getContentType() != null && request.getContentType().toLowerCase().startsWith("application/json")) {
try (ServletInputStream inputStream = request.getInputStream()) {
if (inputStream == null) {
return null;
}
String characterEncoding = Optional.ofNullable(request.getCharacterEncoding()).orElse("UTF-8");
var stringBody = IOUtils.toString(inputStream, characterEncoding);
if (!stringBody.isBlank()) {
return mapper.readTree(stringBody);
}
} catch (IOException e) {
throw new ServletException(e);
}
}
return null;
}

/**
* Sanitize the value and perform the action only when value has changed
*
* @param value String value to sanitize
* @param action action to perform when value has changed
* @return the sanitized value if it has changed
*/
private Optional<String> sanitizeValueAndPerformAction(String value, Consumer<String> action) {
var sanitized = sanitizer.sanitize(value);
if (!sanitized.equals(value)) {
action.accept(sanitized);
return Optional.of(sanitized);
}
return Optional.empty();
}

public List<String> getAttributesExcluded() {
return attributesExcluded;
}

public boolean isSanitizerEnabled() {
return isEnabled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
**/
package org.bonitasoft.console.common.server.preferences.properties;

import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
Expand All @@ -40,6 +38,26 @@ public class SecurityProperties {
*/
public static final String CSRF_PROTECTION = "security.csrf.enabled";

/**
* Property for the (OWASP) Sanitizer protection activation.
* This sanitizer protects against multiple attacks such as XSS, but may restrict the use of some character
* sequences.
*/
public static final String SANITIZER_PROTECTION = "security.sanitizer.enabled";

/**
* Property for the (OWASP) Sanitizer protection.
* The value of this property lists the json attributes that should be excluded when sanitizer protection is active.
*/
public static final String SANITIZER_PROTECTION_EXCLUSIONS = "security.sanitizer.exclude";

/**
* default list of attributes excluded from sanitizer protection when the property is not set in
* security-config.properties
*/
public static final List<String> DEFAULT_SANITIZER_PROTECTION_EXCLUSIONS = List.of("email", "password",
"password_confirm");

/**
* property for the CSRF token cookie to have the secure flag (HTTPS only)
*/
Expand Down Expand Up @@ -85,6 +103,28 @@ public boolean isCSRFProtectionEnabled() {
return res != null && res.equals("true");
}

/**
* @return the value to allow or not Sanitizer activation for protection
*/
public boolean isSanitizerProtectionEnabled() {
final String res = getPlatformProperty(SANITIZER_PROTECTION);
// keep true as default when not set correctly
return !Boolean.FALSE.toString().equalsIgnoreCase(res);
}

/**
* @return the attributes to exclude from Sanitizer protection, comma separated
*/
public List<String> getAttributeExcludedFromSanitizerProtection() {
String excludedAttributes = getPlatformProperty(SANITIZER_PROTECTION_EXCLUSIONS);
if (excludedAttributes == null) {
return DEFAULT_SANITIZER_PROTECTION_EXCLUSIONS;
} else if (excludedAttributes.isBlank()) {
return Collections.emptyList();
}
return Arrays.asList(excludedAttributes.trim().split("\\s*,\\s*"));
}

/**
* @return the value to add or not secure flag to the cookies for CSRF token
*/
Expand Down
12 changes: 12 additions & 0 deletions bpm/bonita-web-server/src/main/webapp/WEB-INF/web.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@
<param-value>true</param-value>
</init-param>
</filter>
<!-- Sanitize filter to prevent code injection -->
<filter>
<filter-name>SanitizerFilter</filter-name>
<filter-class>org.bonitasoft.console.common.server.filter.SanitizerFilter</filter-class>
</filter>
<!-- Cache Filter -->
<filter>
<filter-name>CacheFilter</filter-name>
Expand Down Expand Up @@ -271,6 +276,13 @@
<url-pattern>/portal/custom-page/API/system/session/*</url-pattern>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
<filter-mapping>
<filter-name>SanitizerFilter</filter-name>
<url-pattern>/API/*</url-pattern>
<url-pattern>/APIToolkit/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
<!-- Cache Filter Mapping Start -->
<filter-mapping>
<filter-name>CacheFilter</filter-name>
Expand Down
Loading

0 comments on commit c8e96ff

Please sign in to comment.