Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/main/java/org/codelibs/fess/cors/CorsHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ public CorsHandler() {
*/
protected static final String ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age";

/**
* CORS header for specifying which headers can be exposed to the client.
*/
protected static final String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers";
Comment on lines +64 to +67
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a response header constant for Access-Control-Allow-Private-Network to match the pattern of other CORS response headers, since line 88 in DefaultCorsHandler uses the string literal 'Access-Control-Allow-Private-Network'.

Copilot uses AI. Check for mistakes.

/**
* Request header for preflight requests asking for private network access.
*/
protected static final String ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network";

/**
* Vary header to indicate that response varies based on Origin header.
*/
protected static final String VARY = "Vary";

/**
* Origin header name.
*/
protected static final String ORIGIN = "Origin";

/**
* Processes the CORS request by setting appropriate headers.
*
Expand Down
51 changes: 44 additions & 7 deletions src/main/java/org/codelibs/fess/cors/CorsHandlerFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
*/
package org.codelibs.fess.cors;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

/**
* Factory for managing CORS handlers based on origin.
* Maintains a registry of CORS handlers for different origins and provides lookup functionality.
* This class is thread-safe and supports null origins.
*/
public class CorsHandlerFactory {

Expand All @@ -38,20 +40,45 @@ public CorsHandlerFactory() {

/**
* Map of origin patterns to their corresponding CORS handlers.
* Thread-safe to support dynamic handler registration.
*/
protected Map<String, CorsHandler> handerMap = new HashMap<>();
protected Map<String, CorsHandler> handlerMap = new ConcurrentHashMap<>();

/**
* Handler for null origin.
* Since ConcurrentHashMap does not support null keys, we store null origin separately.
*/
protected final AtomicReference<CorsHandler> nullOriginHandler = new AtomicReference<>();

/**
* Adds a CORS handler for the specified origin.
*
* @param origin the origin pattern (can be "*" for wildcard)
* @param handler the CORS handler to associate with the origin
* @param origin the origin pattern (can be "*" for wildcard, or null)
* @param handler the CORS handler to associate with the origin (can be null to remove)
*/
public void add(final String origin, final CorsHandler handler) {
if (origin == null) {
// Handle null origin separately since ConcurrentHashMap does not support null keys
nullOriginHandler.set(handler);
if (logger.isDebugEnabled()) {
logger.debug("Loaded null origin");
}
return;
}

if (handler == null) {
// ConcurrentHashMap does not support null values, remove the entry instead
handlerMap.remove(origin);
if (logger.isDebugEnabled()) {
logger.debug("Removed {}", origin);
}
return;
}

if (logger.isDebugEnabled()) {
logger.debug("Loaded {}", origin);
}
handerMap.put(origin, handler);
handlerMap.put(origin, handler);
}

/**
Expand All @@ -62,10 +89,20 @@ public void add(final String origin, final CorsHandler handler) {
* @return the CORS handler for the origin, or null if none found
*/
public CorsHandler get(final String origin) {
final CorsHandler handler = handerMap.get(origin);
if (origin == null) {
// Check null origin handler first
final CorsHandler handler = nullOriginHandler.get();
if (handler != null) {
return handler;
}
// Fall back to wildcard
return handlerMap.get("*");
}

final CorsHandler handler = handlerMap.get(origin);
if (handler != null) {
return handler;
}
return handerMap.get("*");
return handlerMap.get("*");
}
}
41 changes: 36 additions & 5 deletions src/main/java/org/codelibs/fess/cors/DefaultCorsHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
*/
public class DefaultCorsHandler extends CorsHandler {

/**
* Cached FessConfig instance for performance optimization.
*/
protected FessConfig fessConfig;

/**
* Creates a new instance of DefaultCorsHandler.
* This constructor initializes the default CORS handler for applying
Expand All @@ -41,32 +46,58 @@ public DefaultCorsHandler() {

/**
* Registers this CORS handler with the factory for configured allowed origins.
* This method is automatically called after bean initialization.
* This method is automatically called after bean initialization and caches
* the FessConfig instance for better performance.
*/
@PostConstruct
public void register() {
final CorsHandlerFactory factory = ComponentUtil.getCorsHandlerFactory();
final FessConfig fessConfig = ComponentUtil.getFessConfig();
fessConfig = ComponentUtil.getFessConfig();
fessConfig.getApiCorsAllowOriginList().forEach(s -> factory.add(s, this));
}

/**
* Processes the CORS request by adding standard CORS headers to the response.
* Headers include allowed origin, methods, headers, max age, and credentials setting.
* Headers include allowed origin, methods, headers, max age, credentials setting,
* expose headers, and vary header for proper caching.
*
* @param origin the origin of the request
* @param request the servlet request
* @param response the servlet response to add CORS headers to
*/
@Override
public void process(final String origin, final ServletRequest request, final ServletResponse response) {
final FessConfig fessConfig = ComponentUtil.getFessConfig();
final HttpServletResponse httpResponse = (HttpServletResponse) response;

// Add standard CORS headers
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_METHODS, fessConfig.getApiCorsAllowMethods());
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_HEADERS, fessConfig.getApiCorsAllowHeaders());
httpResponse.addHeader(ACCESS_CONTROL_MAX_AGE, fessConfig.getApiCorsMaxAge());
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, fessConfig.getApiCorsAllowCredentials());

// Add credentials header only if explicitly set to true (CORS spec compliance)
final String allowCredentials = fessConfig.getApiCorsAllowCredentials();
if ("true".equalsIgnoreCase(allowCredentials)) {
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
}

// Add expose headers if configured
final String exposeHeaders = fessConfig.getApiCorsExposeHeaders();
if (exposeHeaders != null && !exposeHeaders.isEmpty()) {
httpResponse.addHeader(ACCESS_CONTROL_EXPOSE_HEADERS, exposeHeaders);
}

// Add Vary header for proper caching
httpResponse.addHeader(VARY, ORIGIN);

// Handle private network access request if present
if (request instanceof jakarta.servlet.http.HttpServletRequest) {
final jakarta.servlet.http.HttpServletRequest httpRequest = (jakarta.servlet.http.HttpServletRequest) request;
final String privateNetworkRequest = httpRequest.getHeader(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK);
if ("true".equalsIgnoreCase(privateNetworkRequest)) {
httpResponse.addHeader(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, "true");
}
}
}

}
2 changes: 1 addition & 1 deletion src/main/java/org/codelibs/fess/filter/CorsFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public void doFilter(final ServletRequest request, final ServletResponse respons

if (OPTIONS.equals(httpRequest.getMethod())) {
final HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpServletResponse.SC_ACCEPTED);
httpResponse.setStatus(HttpServletResponse.SC_OK);
return;
}
} else if (logger.isDebugEnabled()) {
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
/** The key of the configuration. e.g. true */
String API_CORS_ALLOW_CREDENTIALS = "api.cors.allow.credentials";

/** The key of the configuration. e.g. */
String API_CORS_EXPOSE_HEADERS = "api.cors.expose.headers";

/** The key of the configuration. e.g. false */
String API_JSONP_ENABLED = "api.jsonp.enabled";

Expand Down Expand Up @@ -2614,6 +2617,14 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
*/
boolean isApiCorsAllowCredentials();

/**
* Get the value for the key 'api.cors.expose.headers'. <br>
* The value is, e.g. <br>
* comment: Headers exposed to the client for CORS.
* @return The value of found property. (NotNull: if not found, exception but basically no way)
*/
String getApiCorsExposeHeaders();

/**
* Get the value for the key 'api.jsonp.enabled'. <br>
* The value is, e.g. false <br>
Expand Down Expand Up @@ -9116,6 +9127,10 @@ public boolean isApiCorsAllowCredentials() {
return is(FessConfig.API_CORS_ALLOW_CREDENTIALS);
}

public String getApiCorsExposeHeaders() {
return get(FessConfig.API_CORS_EXPOSE_HEADERS);
}

public String getApiJsonpEnabled() {
return get(FessConfig.API_JSONP_ENABLED);
}
Expand Down Expand Up @@ -12120,6 +12135,7 @@ protected java.util.Map<String, String> prepareGeneratedDefaultMap() {
defaultMap.put(FessConfig.API_CORS_MAX_AGE, "3600");
defaultMap.put(FessConfig.API_CORS_ALLOW_HEADERS, "Origin, Content-Type, Accept, Authorization, X-Requested-With");
defaultMap.put(FessConfig.API_CORS_ALLOW_CREDENTIALS, "true");
defaultMap.put(FessConfig.API_CORS_EXPOSE_HEADERS, "");
defaultMap.put(FessConfig.API_JSONP_ENABLED, "false");
defaultMap.put(FessConfig.API_PING_search_engine_FIELDS, "status,timed_out");
defaultMap.put(FessConfig.VIRTUAL_HOST_HEADERS, "");
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/fess_config.properties
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ api.cors.max.age=3600
api.cors.allow.headers=Origin, Content-Type, Accept, Authorization, X-Requested-With
# Whether to allow credentials for CORS.
api.cors.allow.credentials=true
# Headers exposed to the client for CORS.
api.cors.expose.headers=
# Whether to enable JSONP for API.
api.jsonp.enabled=false
# Fields for API ping to search engine.
Expand Down
130 changes: 128 additions & 2 deletions src/test/java/org/codelibs/fess/cors/CorsHandlerFactoryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,134 @@ public void test_constructor() {

// Verify
assertNotNull(factory);
assertNotNull(factory.handerMap);
assertTrue(factory.handerMap.isEmpty());
assertNotNull(factory.handlerMap);
assertTrue(factory.handlerMap.isEmpty());
assertNotNull(factory.nullOriginHandler);
assertNull(factory.nullOriginHandler.get());
}

// Test thread safety of ConcurrentHashMap
public void test_concurrentAccess() throws Exception {
final int threadCount = 10;
final int operationsPerThread = 100;
final Thread[] threads = new Thread[threadCount];

for (int i = 0; i < threadCount; i++) {
final int threadId = i;
threads[i] = new Thread(() -> {
for (int j = 0; j < operationsPerThread; j++) {
String origin = "https://thread" + threadId + ".example.com";
TestCorsHandler handler = new TestCorsHandler("handler-" + threadId + "-" + j);
corsHandlerFactory.add(origin, handler);
CorsHandler retrieved = corsHandlerFactory.get(origin);
assertNotNull(retrieved);
}
});
}

// Start all threads
for (Thread thread : threads) {
thread.start();
}

// Wait for all threads to complete
for (Thread thread : threads) {
thread.join();
}

// Verify all handlers were added
assertEquals(threadCount, corsHandlerFactory.handlerMap.size());
}

// Test null origin handler with AtomicReference
public void test_nullOriginHandler_threadSafety() throws Exception {
final int threadCount = 10;
final Thread[] threads = new Thread[threadCount];
final TestCorsHandler[] handlers = new TestCorsHandler[threadCount];

for (int i = 0; i < threadCount; i++) {
handlers[i] = new TestCorsHandler("handler-" + i);
final int threadId = i;
threads[i] = new Thread(() -> {
corsHandlerFactory.add(null, handlers[threadId]);
});
}

// Start all threads
for (Thread thread : threads) {
thread.start();
}

// Wait for all threads to complete
for (Thread thread : threads) {
thread.join();
}

// Verify one handler is set (last one wins due to race condition)
CorsHandler result = corsHandlerFactory.get(null);
assertNotNull(result);
boolean foundMatch = false;
for (TestCorsHandler handler : handlers) {
if (result == handler) {
foundMatch = true;
break;
}
}
assertTrue("Result should be one of the handlers", foundMatch);
}

// Test removing handler by setting null value
public void test_removeHandlerBySettingNull() {
// Setup
String origin = "https://example.com";
TestCorsHandler handler = new TestCorsHandler("handler");
corsHandlerFactory.add(origin, handler);

// Verify handler is added
assertNotNull(corsHandlerFactory.get(origin));

// Remove by setting null
corsHandlerFactory.add(origin, null);

// Verify handler is removed (should fall back to wildcard)
CorsHandler result = corsHandlerFactory.get(origin);
// Result will be null if no wildcard handler is set
if (result != null) {
assertEquals(corsHandlerFactory.get("*"), result);
}
}

// Test nullOriginHandler field is accessible
public void test_nullOriginHandlerField() {
assertNotNull("nullOriginHandler field should be initialized", corsHandlerFactory.nullOriginHandler);
}

// Test get with null origin when wildcard exists
public void test_get_nullOriginWithWildcard() {
// Setup
TestCorsHandler wildcardHandler = new TestCorsHandler("wildcard-handler");
corsHandlerFactory.add("*", wildcardHandler);

// Execute - no null origin handler set
CorsHandler result = corsHandlerFactory.get(null);

// Verify - should return wildcard handler
assertEquals(wildcardHandler, result);
}

// Test get with null origin when null handler exists and wildcard exists
public void test_get_nullOriginWithNullHandlerAndWildcard() {
// Setup
TestCorsHandler nullHandler = new TestCorsHandler("null-handler");
TestCorsHandler wildcardHandler = new TestCorsHandler("wildcard-handler");
corsHandlerFactory.add(null, nullHandler);
corsHandlerFactory.add("*", wildcardHandler);

// Execute
CorsHandler result = corsHandlerFactory.get(null);

// Verify - null handler takes precedence over wildcard
assertEquals(nullHandler, result);
}

// Test implementation of CorsHandler for testing purposes
Expand Down
Loading
Loading