Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5a78d54
fix: add security headers to Java backend to address ZAP findings
DerekRoberts Dec 15, 2025
241bdd8
feat: combine best parts from both security headers PRs
DerekRoberts Dec 18, 2025
279c2e4
fix: improve path matching and add test coverage for SecurityHeadersF…
DerekRoberts Dec 18, 2025
7674c0e
Merge branch 'main' into fix/zap-security-headers-java
DerekRoberts Dec 18, 2025
c904d82
fix: use MultivaluedMap instead of HttpHeaders for cookie handling
DerekRoberts Dec 18, 2025
9502885
fix: adjust test expectations for Quarkus-specific endpoints
DerekRoberts Dec 18, 2025
c26ff76
fix: address all Copilot review feedback for SecurityHeadersFilter
DerekRoberts Dec 18, 2025
92b3e40
fix: address additional Copilot review feedback
DerekRoberts Dec 18, 2025
bed232d
fix: replace regex with string check to prevent ReDoS vulnerability
DerekRoberts Dec 18, 2025
8c0c42c
fix: correct length check in path matching logic
DerekRoberts Dec 18, 2025
ab370aa
fix: correct character index in path matching
DerekRoberts Dec 18, 2025
f234c85
fix: address remaining Copilot review feedback
DerekRoberts Dec 19, 2025
2723e8e
fix: address additional Copilot review feedback
DerekRoberts Dec 19, 2025
663a9f1
fix: replace regex with string manipulation to prevent ReDoS
DerekRoberts Dec 19, 2025
255af1a
fix: simplify /q/openapi test logic
DerekRoberts Dec 19, 2025
df15ca3
docs: clarify path matching test comments
DerekRoberts Dec 19, 2025
88f6940
test: add comprehensive path matching unit tests
DerekRoberts Dec 19, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package ca.bc.gov.nrs.api.security;

import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.ext.Provider;
import java.util.ArrayList;
import java.util.List;

/**
* Security headers filter to address ZAP penetration test findings.
*
* Addresses the following ZAP alerts:
* - Content Security Policy (CSP) Header Not Set [10038]
* - Missing Anti-clickjacking Header [10020]
* - Proxy Disclosure [40025]
* - Cookie with SameSite Attribute None [10054]
* - Permissions Policy Header Not Set [10063]
* - Strict-Transport-Security Header Not Set [10035]
* - X-Content-Type-Options Header Missing [10021]
* - Re-examine Cache-control Directives [10015]
* - Non-Storable Content [10049]
* - Storable and Cacheable Content [10049]
*/
@Provider
public class SecurityHeadersFilter implements ContainerResponseFilter {

// Headers to remove to prevent proxy/server disclosure
private static final String[] HEADERS_TO_REMOVE = {
"Server",
"X-Powered-By",
"Via",
"X-AspNet-Version",
"X-AspNetMvc-Version"
};

@Override
public void filter(
ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
MultivaluedMap<String, Object> headers = responseContext.getHeaders();

// Security headers to address ZAP alerts

// X-Content-Type-Options: Prevents MIME type sniffing
// Addresses: X-Content-Type-Options Header Missing [10021]
headers.add("X-Content-Type-Options", "nosniff");

// X-Frame-Options: Prevents clickjacking attacks
// Addresses: Missing Anti-clickjacking Header [10020]
headers.add("X-Frame-Options", "DENY");

// Strict-Transport-Security: Enforces HTTPS
// Addresses: Strict-Transport-Security Header Not Set [10035]
// Only set HSTS when the request is served over HTTPS
// Check both direct HTTPS and proxy-forwarded HTTPS (for reverse proxy scenarios)
boolean isHttps =
requestContext.getUriInfo().getRequestUri().getScheme().equals("https")
|| "https".equalsIgnoreCase(
requestContext.getHeaderString("X-Forwarded-Proto"));
if (isHttps) {
headers.add(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
}

// Content-Security-Policy: Restrictive CSP for API endpoints
// Addresses: Content Security Policy (CSP) Header Not Set [10038]
// Note: This is a restrictive policy suitable for APIs. For web applications with
// inline scripts/styles or external resources, customize this policy accordingly.
headers.add("Content-Security-Policy", "default-src 'self'");

// Permissions-Policy: Controls browser features
// Addresses: Permissions Policy Header Not Set [10063]
headers.add(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(),"
+ " gyroscope=(), speaker-selection=()");
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

The Permissions-Policy header has inconsistent spacing. There is a space after the comma on line 84 (" gyroscope") but this creates a malformed directive. According to the Permissions Policy specification, there should be no spaces after commas between directives, only between the directive name and its allowlist. The space should either be removed or consistently applied to all directives.

Suggested change
+ " gyroscope=(), speaker-selection=()");
+ "gyroscope=(), speaker-selection=()");

Copilot uses AI. Check for mistakes.

// Referrer-Policy: Controls referrer information
headers.add("Referrer-Policy", "strict-origin-when-cross-origin");

// Hide server information (addresses Proxy Disclosure alert [40025])
// Remove proxy/server disclosure headers
for (String headerName : HEADERS_TO_REMOVE) {
headers.remove(headerName);
}

// Fix cookie SameSite attribute - ensure all cookies have SameSite=Strict
// Addresses: Cookie with SameSite Attribute None [10054]
fixCookieSameSiteAttribute(headers);

// Cache-Control headers
// Addresses: Re-examine Cache-control Directives [10015],
// Non-Storable Content [10049], Storable and Cacheable Content [10049]
String path = requestContext.getUriInfo().getPath();
// More specific path matching: /api/v followed by digits matches /api/v1/, /api/v2/, etc.
// but not /api-docs, /api.json, /api/version, /api/veterinary
// Note: /q/* endpoints are handled by Quarkus's internal routing, not JAX-RS,
// so this filter doesn't apply to them. The /q/ check is kept for completeness
// but may not execute in practice.
if (path.matches("^/api/v\\d+.*") || path.startsWith("/q/")) {
// For API endpoints and documentation (Swagger UI), prevent caching
// Use putSingle to replace any existing Cache-Control header
headers.putSingle("Cache-Control", "no-store, no-cache, must-revalidate, private");
headers.putSingle("Pragma", "no-cache");
headers.putSingle("Expires", "0");
} else {
// For static content, allow some caching but with revalidation
// Use putSingle to replace any existing Cache-Control header
headers.putSingle("Cache-Control", "public, max-age=3600, must-revalidate");
}
}

/**
* Ensures all Set-Cookie headers have SameSite=Strict attribute.
* If SameSite is missing or set to None, replaces with Strict.
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

The documentation on line 120 is incomplete. It states "If SameSite is missing or set to None, replaces with Strict" but the implementation also replaces "Lax" with "Strict". The documentation should be updated to accurately reflect the actual behavior: "If SameSite is missing or set to None or Lax, replaces with Strict".

Suggested change
* If SameSite is missing or set to None, replaces with Strict.
* If SameSite is missing or set to None or Lax, replaces with Strict.

Copilot uses AI. Check for mistakes.
*/
@SuppressWarnings("unchecked")
private void fixCookieSameSiteAttribute(MultivaluedMap<String, Object> headers) {
List<Object> setCookieHeaders = headers.get("Set-Cookie");
if (setCookieHeaders == null || setCookieHeaders.isEmpty()) {
return;
}

List<Object> fixedCookies = new ArrayList<>();
for (Object cookieObj : setCookieHeaders) {
String cookie = cookieObj.toString();
String fixedCookie = fixCookieHeader(cookie);
fixedCookies.add(fixedCookie);
}

headers.put("Set-Cookie", fixedCookies);
}

/**
* Fixes a single Set-Cookie header to ensure SameSite=Strict is set.
* Handles existing SameSite values (None, Lax, Strict) to prevent duplicates.
*/
private String fixCookieHeader(String cookie) {
if (cookie == null || cookie.isEmpty()) {
return cookie;
}

String cookieLower = cookie.toLowerCase();

// Check if SameSite is already present with any value
if (cookieLower.contains("samesite")) {
// Early return if already Strict (no need to process)
if (cookieLower.contains("samesite=strict") || cookieLower.contains("samesite = strict")) {
return cookie;
}
// Replace any existing SameSite value with Strict
// Handle both "; SameSite=None" and "SameSite=None" patterns, allowing spaces around '='
cookie = cookie.replaceAll("(?i);\\s*samesite\\s*=\\s*(none|lax|strict)", "; SameSite=Strict");
cookie = cookie.replaceAll("(?i)^samesite\\s*=\\s*(none|lax|strict)", "SameSite=Strict");
return cookie;
}

// Add SameSite=Strict if not present
// Insert before the earliest HttpOnly, Secure, or Path attribute (if any)
int httpOnlyIndex = cookie.indexOf("; HttpOnly");
int secureIndex = cookie.indexOf("; Secure");
int pathIndex = cookie.indexOf("; Path=");
Comment on lines +206 to +208
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

The cookie attribute detection logic has a bug when checking for HttpOnly, Secure, and Path attributes. The code uses case-sensitive indexOf to find these attributes (e.g., cookie.indexOf("; HttpOnly")), which will fail to detect these attributes if they have different casing (e.g., "; httponly", "; HTTPONLY"). Cookie attributes are case-insensitive according to RFC 6265, so this could result in incorrect insertion position for the SameSite attribute. Consider using case-insensitive matching or converting to lowercase for comparison.

Copilot uses AI. Check for mistakes.

int insertPos = -1;
if (httpOnlyIndex != -1) {
insertPos = httpOnlyIndex;
}
if (secureIndex != -1 && (insertPos == -1 || secureIndex < insertPos)) {
insertPos = secureIndex;
}
if (pathIndex != -1 && (insertPos == -1 || pathIndex < insertPos)) {
insertPos = pathIndex;
}

if (insertPos != -1) {
// Insert before the first attribute found
cookie = cookie.substring(0, insertPos) + "; SameSite=Strict" + cookie.substring(insertPos);
} else {
// No attributes found, append at the end
cookie = cookie + "; SameSite=Strict";
}

return cookie;
}
}
11 changes: 11 additions & 0 deletions backend-java/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,14 @@ quarkus.flyway.connect-retries=10
quarkus.swagger-ui.always-include=true
# Combined native build args: compatibility and runtime initialization fixes
quarkus.native.additional-build-args=-march=compatibility,--initialize-at-run-time=net.datafaker.service.RandomService\\,sun.java2d.pipe.Region\\,sun.java2d.Disposer

# Security configuration to address ZAP penetration test findings
# Hide server information (addresses Proxy Disclosure alert [40025])
quarkus.http.header.server.enabled=false

# Cookie SameSite configuration (addresses Cookie with SameSite Attribute None [10054])
# Note: SecurityHeadersFilter enforces SameSite=Strict for all cookies.
# Application-level cookie configuration will be overridden by the security filter.
# Example for session cookies (SecurityHeadersFilter enforces SameSite=Strict):
# quarkus.http.same-site-cookie.session.value=Strict
# quarkus.http.same-site-cookie.session.secure=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package ca.bc.gov.nrs.api.security;

import org.junit.jupiter.api.Test;

import java.lang.reflect.Method;

import static org.junit.jupiter.api.Assertions.*;

/**
* Unit tests for cookie SameSite attribute handling in SecurityHeadersFilter.
* Tests the fixCookieHeader method logic directly.
*/
class SecurityHeadersFilterCookieTest {

private SecurityHeadersFilter filter = new SecurityHeadersFilter();

/**
* Uses reflection to access the private fixCookieHeader method for testing.
*/
private String fixCookieHeader(String cookie) throws Exception {
Method method = SecurityHeadersFilter.class.getDeclaredMethod("fixCookieHeader", String.class);
method.setAccessible(true);
return (String) method.invoke(filter, cookie);
}

@Test
void testCookieWithoutSameSite() throws Exception {
String cookie = "sessionId=abc123";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should add SameSite=Strict");
assertEquals("sessionId=abc123; SameSite=Strict", result);
}

@Test
void testCookieWithSameSiteNone() throws Exception {
String cookie = "sessionId=abc123; SameSite=None";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should replace SameSite=None with Strict");
assertFalse(result.contains("SameSite=None"), "Should not contain SameSite=None");
}

@Test
void testCookieWithSameSiteLax() throws Exception {
String cookie = "sessionId=abc123; SameSite=Lax";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should replace SameSite=Lax with Strict");
assertFalse(result.contains("SameSite=Lax"), "Should not contain SameSite=Lax");
}

@Test
void testCookieWithSameSiteStrict() throws Exception {
String cookie = "sessionId=abc123; SameSite=Strict";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should keep SameSite=Strict");
// Should not duplicate
assertEquals(1, (result.length() - result.replace("SameSite=Strict", "").length()) / "SameSite=Strict".length());
}

@Test
void testCookieWithHttpOnly() throws Exception {
String cookie = "sessionId=abc123; HttpOnly";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should add SameSite=Strict");
assertTrue(result.contains("HttpOnly"), "Should preserve HttpOnly");
// SameSite should come before HttpOnly
int sameSiteIndex = result.indexOf("SameSite=Strict");
int httpOnlyIndex = result.indexOf("HttpOnly");
assertTrue(sameSiteIndex < httpOnlyIndex, "SameSite should come before HttpOnly");
}

@Test
void testCookieWithSecure() throws Exception {
String cookie = "sessionId=abc123; Secure";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should add SameSite=Strict");
assertTrue(result.contains("Secure"), "Should preserve Secure");
// SameSite should come before Secure
int sameSiteIndex = result.indexOf("SameSite=Strict");
int secureIndex = result.indexOf("Secure");
assertTrue(sameSiteIndex < secureIndex, "SameSite should come before Secure");
}

@Test
void testCookieWithPath() throws Exception {
String cookie = "sessionId=abc123; Path=/";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should add SameSite=Strict");
assertTrue(result.contains("Path=/"), "Should preserve Path");
// SameSite should come before Path
int sameSiteIndex = result.indexOf("SameSite=Strict");
int pathIndex = result.indexOf("Path=");
assertTrue(sameSiteIndex < pathIndex, "SameSite should come before Path");
}

@Test
void testCookieWithMultipleAttributes() throws Exception {
// Test with HttpOnly, Secure, and Path - should insert before the earliest one
String cookie = "sessionId=abc123; Secure; HttpOnly; Path=/";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should add SameSite=Strict");
assertTrue(result.contains("Secure"), "Should preserve Secure");
assertTrue(result.contains("HttpOnly"), "Should preserve HttpOnly");
assertTrue(result.contains("Path=/"), "Should preserve Path");
// SameSite should come before Secure (the earliest attribute)
int sameSiteIndex = result.indexOf("SameSite=Strict");
int secureIndex = result.indexOf("Secure");
assertTrue(sameSiteIndex < secureIndex, "SameSite should come before Secure");
}

@Test
void testCookieWithCaseInsensitiveSameSite() throws Exception {
String cookie = "sessionId=abc123; samesite=none";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should replace case-insensitive SameSite=None");
assertFalse(result.toLowerCase().contains("samesite=none"), "Should not contain SameSite=None");
}

@Test
void testCookieWithSpacesInSameSite() throws Exception {
String cookie = "sessionId=abc123; SameSite = None";
String result = fixCookieHeader(cookie);
assertTrue(result.contains("SameSite=Strict"), "Should handle spaces in SameSite attribute");
// Verify the original value with spaces was replaced, not just added
assertFalse(result.contains("= None"), "Should not contain original SameSite = None");
assertFalse(result.toLowerCase().contains("samesite = none"), "Should not contain original SameSite = None (case-insensitive)");
}

@Test
void testNullCookie() throws Exception {
String result = fixCookieHeader(null);
assertNull(result, "Should return null for null input");
}

@Test
void testEmptyCookie() throws Exception {
String result = fixCookieHeader("");
assertEquals("", result, "Should return empty string for empty input");
}
}
Loading
Loading