Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conf/openmetadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ server:

# Response compression disabled for maximum throughput
gzip:
enabled: false
enabled: true

# Thread pool configuration
# With virtual threads enabled (Java 21+), these settings become less critical
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,24 @@
import io.dropwizard.servlets.assets.AssetServlet;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
import org.openmetadata.service.config.OMWebConfiguration;
import org.openmetadata.service.resources.system.IndexResource;

@Slf4j
public class OpenMetadataAssetServlet extends AssetServlet {
private final OMWebConfiguration webConfiguration;
private final String basePath;
private final String resourcePath;

public OpenMetadataAssetServlet(
String basePath,
Expand All @@ -36,6 +44,7 @@ public OpenMetadataAssetServlet(
@Nullable String indexFile,
OMWebConfiguration webConf) {
super(resourcePath, uriPath, indexFile, "text/html", StandardCharsets.UTF_8);
this.resourcePath = resourcePath;
this.webConfiguration = webConf;
this.basePath = basePath;
}
Expand All @@ -54,6 +63,41 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
return;
}

String acceptEncoding = req.getHeader("Accept-Encoding");

// 1. Check for Brotli (br)
if (supportsEncoding(acceptEncoding, "br")) {
try {
String fullResourcePath = getPathToCheck(req, requestUri, ".br");
if (fullResourcePath != null) {
URL url = this.getClass().getResource(fullResourcePath);
if (url != null) {
serveCompressed(req, resp, requestUri, "br", "br");
return;
}
}
} catch (Exception e) {
LOG.debug("Failed to serve Brotli compressed asset for {}: {}", requestUri, e.getMessage());
}
}

// 2. Check for Gzip
if (supportsEncoding(acceptEncoding, "gzip")) {
try {
String fullResourcePath = getPathToCheck(req, requestUri, ".gz");
if (fullResourcePath != null) {
URL url = this.getClass().getResource(fullResourcePath);

if (url != null) {
serveCompressed(req, resp, requestUri, "gzip", "gz");
return;
}
}
} catch (Exception e) {
LOG.debug("Failed to serve Gzip compressed asset for {}: {}", requestUri, e.getMessage());
}
}

super.doGet(req, resp);

// For SPA routing: serve index.html for 404s that don't look like static asset requests
Expand All @@ -70,10 +114,112 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
}

/**
* Check if the request URI looks like a SPA route (not a static asset)
* Check if the Accept-Encoding header supports the given encoding with non-zero quality value.
* Handles q-values properly (e.g., "br;q=0" means encoding is explicitly disabled).
*/
private boolean supportsEncoding(String acceptEncoding, String encoding) {
if (acceptEncoding == null || acceptEncoding.isEmpty()) {
return false;
}

// Split by comma to handle multiple encodings
String[] encodings = acceptEncoding.toLowerCase().split(",");
for (String enc : encodings) {
enc = enc.trim();

// Check if this encoding matches
if (enc.startsWith(encoding)) {
// Check for q=0 which explicitly disables the encoding
return !enc.contains("q=0");
}
}
return false;
}

private String getPathToCheck(HttpServletRequest req, String requestUri, String extension) {
String pathToCheck = requestUri;
String contextPath = req.getContextPath();
if (contextPath != null && requestUri.startsWith(contextPath)) {
pathToCheck = requestUri.substring(contextPath.length());
}

// Reject path traversal attempts early
if (pathToCheck.contains("..")) {
LOG.warn("Path traversal attempt detected in request: {}", requestUri);
return null;
}

String fullPath =
this.resourcePath + (pathToCheck.startsWith("/") ? "" : "/") + pathToCheck + extension;

// Validate against path traversal attacks
try {
Path normalizedPath = Paths.get(fullPath).normalize();
Path baseResourcePath = Paths.get(this.resourcePath).normalize();

// Check path is within resource directory
if (!normalizedPath.startsWith(baseResourcePath)) {
LOG.warn("Path traversal attempt detected: {} escaped resource directory", requestUri);
return null;
}

// Additional check: normalized path should not go backwards
if (normalizedPath.toString().contains("..")) {
LOG.warn("Path contains .. after normalization: {}", requestUri);
return null;
}
} catch (Exception e) {
LOG.debug("Path validation failed for {}: {}", requestUri, e.getMessage());
return null;
}

return fullPath;
}

private void serveCompressed(
HttpServletRequest req,
HttpServletResponse resp,
String requestUri,
String contentEncoding,
String extension)
throws ServletException, IOException {
resp.setHeader("Content-Encoding", contentEncoding);
String mimeType = req.getServletContext().getMimeType(requestUri);

HttpServletRequestWrapper compressedReq =
new HttpServletRequestWrapper(req) {
@Override
public String getPathInfo() {
String pathInfo = super.getPathInfo();
return pathInfo != null ? pathInfo + "." + extension : null;
}

@Override
public String getRequestURI() {
return super.getRequestURI() + "." + extension;
}
};

HttpServletResponseWrapper compressedResp =
new HttpServletResponseWrapper(resp) {
@Override
public void setContentType(String type) {
if (mimeType != null) {
super.setContentType(mimeType);
} else {
super.setContentType(type);
}
}
};

super.doGet(compressedReq, compressedResp);
}

/**
* Check if the request URI looks like an SPA route (not a static asset)
* Static assets typically have file extensions, SPA routes don't
* @param requestUri The request URI to check
* @return true if this should be treated as a SPA route, false if it's a static asset
* @return true if this should be treated as an SPA route, false if it's a static asset
*/
private boolean isSpaRoute(String requestUri) {
// Remove base path if present
Expand All @@ -90,11 +236,8 @@ private boolean isSpaRoute(String requestUri) {
// If path has a file extension, it's likely a static asset
// Don't serve index.html for these
String fileName = pathToCheck.substring(pathToCheck.lastIndexOf('/') + 1);
if (fileName.contains(".")) {
return false; // Has extension, likely a static asset
}
return !fileName.contains("."); // Has extension, likely a static asset

// No file extension, treat as SPA route
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package org.openmetadata.service.socket;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.openmetadata.service.config.OMWebConfiguration;

public class OpenMetadataAssetServletTest {

private OpenMetadataAssetServlet servlet;

@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@Mock private ServletContext servletContext;
@Mock private OMWebConfiguration webConfiguration;
@Mock private ServletOutputStream outputStream;

@BeforeEach
public void setup() throws Exception {
MockitoAnnotations.openMocks(this);
when(request.getServletContext()).thenReturn(servletContext);
when(response.getOutputStream()).thenReturn(outputStream);

// Initialize servlet with /assets as resource path
servlet = new OpenMetadataAssetServlet("/", "/assets", "/", "index.html", webConfiguration);
}

@Test
public void testServeGzipAsset() throws Exception {
// Setup request for test.js
String path = "/test.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn("gzip, deflate");
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);
when(servletContext.getMimeType(anyString())).thenReturn("application/javascript");

try {
servlet.doGet(request, response);
} catch (Exception e) {
e.printStackTrace();
throw e;
}

// Verify Content-Encoding header is set
verify(response).setHeader("Content-Encoding", "gzip");
// Verify Content-Type is set to javascript (not octet-stream or whatever .gz might imply)
verify(response).setContentType("application/javascript");
}

@Test
public void testServeNormalAssetIfGzipMissing() throws Exception {
// Setup request for normal.js (which has no .gz version)
String path = "/normal.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn("gzip, deflate");
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);

servlet.doGet(request, response);

// Verify Content-Encoding is NOT set
verify(response, never()).setHeader(eq("Content-Encoding"), anyString());
}

@Test
public void testServeNormalAssetIfGzipNotAccepted() throws Exception {
// Setup request for test.js (which HAS .gz version)
String path = "/test.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn(null);
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);

servlet.doGet(request, response);

// Verify Content-Encoding is NOT set
verify(response, never()).setHeader(eq("Content-Encoding"), anyString());
}

@Test
public void testServeBrotliAsset() throws Exception {
// Setup request for test.js (which has .br version)
String path = "/test.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn("br"); // Only asking for br
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);
when(servletContext.getMimeType(anyString())).thenReturn("application/javascript");

try {
servlet.doGet(request, response);
} catch (Exception e) {
e.printStackTrace();
throw e;
}

// Verify Content-Encoding is br
verify(response).setHeader("Content-Encoding", "br");
verify(response).setContentType("application/javascript");
}

@Test
public void testPrioritizeBrotliOverGzip() throws Exception {
// Setup request for test.js (which has BOTH .br and .gz)
String path = "/test.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn("gzip, deflate, br"); // Asking for both
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);
when(servletContext.getMimeType(anyString())).thenReturn("application/javascript");

try {
servlet.doGet(request, response);
} catch (Exception e) {
e.printStackTrace();
throw e;
}

// Verify Content-Encoding is br (Prioritized)
verify(response).setHeader("Content-Encoding", "br");
verify(response).setContentType("application/javascript");
}

@Test
public void testFallbackToGzipIfBrotliMissing() throws Exception {
// Setup request for gzip_only.js (which has .gz but NO .br)
String path = "/gzip_only.js";
when(request.getRequestURI()).thenReturn(path);
when(request.getContextPath()).thenReturn("");
when(request.getPathInfo()).thenReturn(path);
when(request.getServletPath()).thenReturn("");
when(request.getHeader("Accept-Encoding")).thenReturn("gzip, deflate, br"); // Asking for both
when(request.getMethod()).thenReturn("GET");
when(request.getDateHeader(anyString())).thenReturn(-1L);
when(request.getHeader("If-None-Match")).thenReturn(null);
when(request.getHeader("If-Modified-Since")).thenReturn(null);
when(servletContext.getMimeType(anyString())).thenReturn("application/javascript");

try {
servlet.doGet(request, response);
} catch (Exception e) {
e.printStackTrace();
throw e;
}

// Verify Content-Encoding is gzip (Fallback)
verify(response).setHeader("Content-Encoding", "gzip");
verify(response).setContentType("application/javascript");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('gzip only');
Loading
Loading