Skip to content

Commit

Permalink
[GEOS-11300] Centralize access to static web files
Browse files Browse the repository at this point in the history
  • Loading branch information
sikeoka authored and aaime committed Feb 19, 2024
1 parent db53c53 commit 9b0249c
Show file tree
Hide file tree
Showing 11 changed files with 344 additions and 90 deletions.
8 changes: 8 additions & 0 deletions doc/en/user/source/production/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ layers, styles, etc.) in URL paths need to contain encoded percent, encoded peri
The ``GEOSERVER_USE_STRICT_FIREWALL`` property can be set to false either via Java system property, command line argument
(-D), environment variable or web.xml init parameter to use the more lenient DefaultHttpFirewall.

Static Web Files
----------------

GeoServer by default allows administrators to serve static files by simply placing them in the ``www``` subdirectory of the
GeoServer data directory. If this feature is not being used to serve HTML/JavaScript files or is not being used at all, the
``GEOSERVER_DISABLE_STATIC_WEB_FILES`` property can be set to true to mitigate potential stored XSS issues with that directory.
See the :ref:`tutorials_staticfiles` page for more details.

Session Management
------------------

Expand Down
7 changes: 6 additions & 1 deletion doc/en/user/source/tutorials/staticfiles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ You can place static files in the ``www`` subdirectory of the GeoServer :ref:`da

This approach has some limitations:

* GeoServer can only serve files whose MIME type is recognized. If you get an HTTP 415 error, this is because GeoServer cannot determine a file's MIME type.
* This approach does not make use of accelerators such as the `Tomcat APR library <http://tomcat.apache.org/tomcat-7.0-doc/apr.html>`_. If you have many static files to be served at high speed, you may wish to create your own web app to be deployed along with GeoServer or use a separate web server to serve the content.

The ``GEOSERVER_DISABLE_STATIC_WEB_FILES`` property can be set to true convert the ``text/html`` and ``application/javascript``
content types to ``text/plain`` in the ``Content-Type`` HTTP response header which will prevent web pages from being served
through the ``www`` directory. This will help to prevent stored cross-site scripting vulnerabilities if the ``www`` directory
is not being used at all or if it is only used to serve files other than web pages, such as PDF or Word documents. The default
behavior is to **NOT** convert these content types. This property can be set either via Java system property, command line
argument (-D), environment variable or web.xml init parameter.
30 changes: 17 additions & 13 deletions src/main/src/main/java/org/geoserver/ows/StylePublisher.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import java.io.IOException;
import java.net.URL;
import java.net.URLDecoder;
import javax.servlet.http.HttpServletRequest;
import org.geoserver.catalog.Catalog;
import org.geoserver.catalog.WorkspaceInfo;
Expand All @@ -16,6 +15,7 @@
import org.geoserver.platform.resource.Resource;
import org.geoserver.platform.resource.Resource.Type;
import org.geotools.util.URLs;
import org.springframework.http.MediaType;
import org.springframework.web.context.support.ServletContextResourceLoader;

/**
Expand All @@ -39,22 +39,26 @@ public StylePublisher(Catalog catalog) {
}

@Override
protected boolean isAttachment(URL url, String filename, String mime) {
// prevent stored XSS using malicious style resources with the
// text/html, text/xml, application/xml or image/svg+xml mime types
String lowerCaseMime = mime.toLowerCase();
return lowerCaseMime.contains("xml")
|| lowerCaseMime.contains("html")
|| super.isAttachment(url, filename, mime);
protected String getMimeType(String reqPath, String filename) {
String mimeType = super.getMimeType(reqPath, filename);
String lowerCaseMime = mimeType.toLowerCase();
// force using the static web files directory for html/javascript files to mitigate stored
// XSS vulnerabilities
if (lowerCaseMime.contains("html") || lowerCaseMime.contains("javascript")) {
return MediaType.TEXT_PLAIN_VALUE;
}
return mimeType;
}

@Override
protected URL getUrl(HttpServletRequest request) throws IOException {
String ctxPath = request.getContextPath();
String reqPath = request.getRequestURI();
reqPath = URLDecoder.decode(reqPath, "UTF-8");
reqPath = reqPath.substring(ctxPath.length());
protected boolean isAttachment(String reqPath, String filename, String mime) {
// prevent stored XSS using malicious style resources with the
// text/xml, application/xml or image/svg+xml mime types
return mime.toLowerCase().contains("xml") || super.isAttachment(reqPath, filename, mime);
}

@Override
protected URL getUrl(HttpServletRequest request, String reqPath) throws IOException {
if ((reqPath.length() > 1) && reqPath.startsWith("/")) {
reqPath = reqPath.substring(1);
}
Expand Down
88 changes: 54 additions & 34 deletions src/main/src/test/java/org/geoserver/ows/StylePublisherTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -115,50 +115,70 @@ public void testBlockXML() throws Exception {
}

@Test
public void testDefaultContentType() throws Exception {
public void testContentTypeDefault() throws Exception {
// test that the application/octect-stream content type is used if
// the servlet context doesn't contain a mapping for the mime type.
String[] path = {"styles/test.bar"};
MockHttpServletResponse response = request(path, null);
assertEquals(200, response.getStatus());
assertThat(
response.getHeader("Content-Type"),
startsWith(MediaType.APPLICATION_OCTET_STREAM_VALUE));
doTestTypeAndDisposition(
"test.bar", null, MediaType.APPLICATION_OCTET_STREAM_VALUE, "inline");
}

@Test
public void testForceDownload() throws Exception {
// test that style resources with certain mime types have a Content-Disposition
public void testContentTypeTextXml() throws Exception {
// test that the text/xml content type has a Content-Disposition
// to force the web browser to download the file
String[] path = {"styles/test.foo"};
context.addMimeType("foo", MediaType.TEXT_XML);
MockHttpServletResponse response1 = request(path, null);
assertEquals(200, response1.getStatus());
assertThat(response1.getHeader("Content-Type"), startsWith(MediaType.TEXT_XML_VALUE));
assertEquals(
"attachment; filename=\"test.foo\"", response1.getHeader("Content-Disposition"));
doTestTypeAndDisposition(
"test.foo", MediaType.TEXT_XML, MediaType.TEXT_XML_VALUE, "attachment");
}

context.addMimeType("foo", MediaType.APPLICATION_XML);
MockHttpServletResponse response2 = request(path, null);
assertEquals(200, response2.getStatus());
assertThat(
response2.getHeader("Content-Type"), startsWith(MediaType.APPLICATION_XML_VALUE));
assertEquals(
"attachment; filename=\"test.foo\"", response2.getHeader("Content-Disposition"));
@Test
public void testContentTypeApplicationXml() throws Exception {
// test that the application/xml content type has a Content-Disposition
// to force the web browser to download the file
doTestTypeAndDisposition(
"test.foo",
MediaType.APPLICATION_XML,
MediaType.APPLICATION_XML_VALUE,
"attachment");
}

context.addMimeType("foo", MediaType.TEXT_HTML);
MockHttpServletResponse response3 = request(path, null);
assertEquals(200, response3.getStatus());
assertThat(response3.getHeader("Content-Type"), startsWith(MediaType.TEXT_HTML_VALUE));
assertEquals(
"attachment; filename=\"test.foo\"", response3.getHeader("Content-Disposition"));
@Test
public void testContentTypeImageSvgXml() throws Exception {
// test that the image/svg+xml content type has a Content-Disposition
// to force the web browser to download the file
doTestTypeAndDisposition(
"test.foo", MediaType.valueOf("image/svg+xml"), "image/svg+xml", "attachment");
}

@Test
public void testContentTypeTextHtml() throws Exception {
// test that the text/html content type is replaced with text/plain
doTestTypeAndDisposition(
"test.foo", MediaType.TEXT_HTML, MediaType.TEXT_PLAIN_VALUE, "inline");
}

context.addMimeType("foo", MediaType.valueOf("image/svg+xml"));
MockHttpServletResponse response4 = request(path, null);
assertEquals(200, response4.getStatus());
assertThat(response4.getHeader("Content-Type"), startsWith("image/svg+xml"));
@Test
public void testContentTypeApplicationJavascript() throws Exception {
// test that the application/javascript content type is replaced with text/plain
doTestTypeAndDisposition(
"test.foo",
MediaType.valueOf("application/javascript"),
MediaType.TEXT_PLAIN_VALUE,
"inline");
}

private void doTestTypeAndDisposition(
String filename, MediaType serverType, String responseType, String dispositionType)
throws Exception {
String[] path = {"styles/" + filename};
if (serverType != null) {
context.addMimeType("foo", serverType);
}
MockHttpServletResponse response = request(path, null);
assertEquals(200, response.getStatus());
assertThat(response.getHeader("Content-Type"), startsWith(responseType));
assertEquals(
"attachment; filename=\"test.foo\"", response4.getHeader("Content-Disposition"));
dispositionType + "; filename=\"" + filename + "\"",
response.getHeader("Content-Disposition"));
}

@Test
Expand Down
45 changes: 35 additions & 10 deletions src/ows/src/main/java/org/geoserver/ows/AbstractURLPublisher.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import java.io.OutputStream;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Enumeration;
import java.util.Locale;
Expand Down Expand Up @@ -54,10 +56,13 @@
*/
public abstract class AbstractURLPublisher extends AbstractController {

protected boolean replaceWindowsFileSeparator = false;

@Override
protected ModelAndView handleRequestInternal(
HttpServletRequest request, HttpServletResponse response) throws Exception {
URL url = getUrl(request);
String reqPath = getRequestPath(request);
URL url = getUrl(request, reqPath);

// if not found return a 404
if (url == null) {
Expand All @@ -81,15 +86,10 @@ protected ModelAndView handleRequestInternal(
return null;
}

// set the mime if known by the servlet container, otherwise default to
// application/octet-stream to mitigate potential cross-site scripting
String filename = new File(url.getFile()).getName();
String mime =
Optional.ofNullable(getServletContext())
.map(sc -> sc.getMimeType(filename))
.orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
String mime = getMimeType(reqPath, filename);
response.setContentType(mime);
String dispositionType = isAttachment(url, filename, mime) ? "attachment" : "inline";
String dispositionType = isAttachment(reqPath, filename, mime) ? "attachment" : "inline";
response.setHeader(
"Content-Disposition",
ContentDisposition.builder(dispositionType).filename(filename).build().toString());
Expand Down Expand Up @@ -151,6 +151,19 @@ private boolean checkNotModified(HttpServletRequest request, long timeStamp) {
return false;
}

private String getRequestPath(HttpServletRequest request) throws IOException {
String reqPath =
URLDecoder.decode(request.getRequestURI(), "UTF-8")
.substring(request.getContextPath().length());
if (this.replaceWindowsFileSeparator) {
reqPath = reqPath.replace(File.separatorChar, '/');
}
if (Arrays.stream(reqPath.split("/")).anyMatch(".."::equals)) {
throw new IllegalArgumentException("Contains invalid '..' path: " + reqPath);
}
return reqPath;
}

static String lastModified(long timeStamp) {
SimpleDateFormat format = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss", Locale.ENGLISH);
format.setTimeZone(TimeZone.getTimeZone("GMT"));
Expand All @@ -170,14 +183,26 @@ static long lastModified(String timeStamp) {
return 1000 * (ifModifiedSince / 1000);
}

/**
* Can be overridden to replace specific mime types to mitigate potential XSS issues with
* certain resources
*/
protected String getMimeType(String reqPath, String filename) {
// set the mime if known by the servlet container, otherwise default to
// application/octet-stream to mitigate potential cross-site scripting
return Optional.ofNullable(getServletContext())
.map(sc -> sc.getMimeType(filename))
.orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
}

/**
* Can be overridden to set the Content-Disposition: attachment to mitigate potential XSS issues
* with certain resources
*/
protected boolean isAttachment(URL url, String filename, String mime) {
protected boolean isAttachment(String reqPath, String filename, String mime) {
return false;
}

/** Retrieves the resource URL from the specified request */
protected abstract URL getUrl(HttpServletRequest request) throws IOException;
protected abstract URL getUrl(HttpServletRequest request, String reqPath) throws IOException;
}
16 changes: 3 additions & 13 deletions src/ows/src/main/java/org/geoserver/ows/ClasspathPublisher.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@
*/
package org.geoserver.ows;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import javax.servlet.http.HttpServletRequest;

/**
Expand Down Expand Up @@ -47,22 +44,15 @@ public class ClasspathPublisher extends AbstractURLPublisher {
*/
public ClasspathPublisher(Class<?> clazz) {
this.clazz = clazz;
this.replaceWindowsFileSeparator = true;
}

public ClasspathPublisher() {
this.clazz = ClasspathPublisher.class;
this(ClasspathPublisher.class);
}

@Override
protected URL getUrl(HttpServletRequest request) throws IOException {
String reqPath =
URLDecoder.decode(request.getRequestURI(), "UTF-8")
.substring(request.getContextPath().length())
.replace(File.separatorChar, '/');
if (Arrays.stream(reqPath.split("/")).anyMatch(".."::equals)) {
throw new IllegalArgumentException("Contains invalid '..' path: " + reqPath);
}

protected URL getUrl(HttpServletRequest request, String reqPath) throws IOException {
// try a few lookups
URL url = clazz.getResource(reqPath);
if (url == null && !reqPath.startsWith("/")) {
Expand Down
51 changes: 37 additions & 14 deletions src/ows/src/main/java/org/geoserver/ows/FilePublisher.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLDecoder;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import org.geoserver.platform.GeoServerExtensions;
import org.geoserver.platform.GeoServerResourceLoader;
import org.geotools.util.URLs;
import org.springframework.http.MediaType;
import org.springframework.web.context.support.ServletContextResource;
import org.springframework.web.context.support.ServletContextResourceLoader;

Expand Down Expand Up @@ -40,6 +41,14 @@
* @author Justin Deoliveira, The Open Planning Project
*/
public class FilePublisher extends AbstractURLPublisher {

/**
* System property to control whether or not to disable server static files with the text/html
* or application/javascript mime types. When set to true, these mime types will be converted to
* text/plain. Default is false.
*/
public static final String DISABLE_STATIC_WEB_FILES = "GEOSERVER_DISABLE_STATIC_WEB_FILES";

/** Resource loader */
protected GeoServerResourceLoader loader;

Expand All @@ -61,24 +70,38 @@ protected void initServletContext(ServletContext servletContext) {
}

@Override
protected URL getUrl(HttpServletRequest request) throws IOException {
String ctxPath = request.getContextPath();
String reqPath = request.getRequestURI();
reqPath = URLDecoder.decode(reqPath, "UTF-8");
reqPath = reqPath.substring(ctxPath.length());
protected String getMimeType(String reqPath, String filename) {
String mimeType = super.getMimeType(reqPath, filename);
if (!"/index.html".equals(reqPath)) {
String lowerCaseMime = mimeType.toLowerCase();
// force using the static web files directory for html/javascript files and only allow
// the html/javascript mime types when the system property is set to true to mitigate
// stored XSS vulnerabilities
if (lowerCaseMime.contains("html") || lowerCaseMime.contains("javascript")) {
String prop = GeoServerExtensions.getProperty(DISABLE_STATIC_WEB_FILES);
if (Boolean.parseBoolean(prop) || !reqPath.startsWith("/www/")) {
return MediaType.TEXT_PLAIN_VALUE;
}
}
}
return mimeType;
}

@Override
protected boolean isAttachment(String reqPath, String filename, String mime) {
// prevent stored XSS using malicious resources with the
// text/xml, application/xml or image/svg+xml mime types
return mime.toLowerCase().contains("xml") || super.isAttachment(reqPath, filename, mime);
}

@Override
protected URL getUrl(HttpServletRequest request, String reqPath) throws IOException {
if ((reqPath.length() > 1) && reqPath.startsWith("/")) {
reqPath = reqPath.substring(1);
}

// sigh, in order to serve the file we have to open it 2 times
// 1) to determine its mime type
// 2) to determine its encoding and really serve it
// we can't coalish 1) because we don't have a way to give jmimemagic the bytes at the
// beginning of the file without disabling extension quick matching

// load the file
File file = loader.find(reqPath);
// load the file (do not load index.html from the data directory)
File file = "index.html".equals(reqPath) ? null : loader.find(reqPath);

if (file == null && scloader != null) {
// try loading as a servlet resource
Expand Down
Loading

0 comments on commit 9b0249c

Please sign in to comment.