Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,257 @@
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)
// Check multiple common proxy headers to ensure HSTS is applied correctly
String scheme = requestContext.getUriInfo().getRequestUri().getScheme();
String xForwardedProto = requestContext.getHeaderString("X-Forwarded-Proto");
String xForwardedScheme = requestContext.getHeaderString("X-Forwarded-Scheme");
String xForwardedSsl = requestContext.getHeaderString("X-Forwarded-SSL");
String frontEndHttps = requestContext.getHeaderString("Front-End-Https");
boolean isHttps = "https".equals(scheme)
|| "https".equalsIgnoreCase(xForwardedProto)
|| "https".equalsIgnoreCase(xForwardedScheme)
|| "on".equalsIgnoreCase(xForwardedSsl)
|| "on".equalsIgnoreCase(frontEndHttps)
|| "true".equalsIgnoreCase(frontEndHttps);
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();
boolean isApiVersionPath = isApiVersionPath(path);
if (isApiVersionPath || 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.
* Package-private for testing purposes.
*/
String fixCookieHeader(String cookie) {
if (cookie == null || cookie.isEmpty()) {
return cookie;
}

// Use string manipulation instead of regex to prevent ReDoS vulnerability
// Split on ';' to examine individual attributes without using regex
String[] parts = cookie.split(";", -1); // -1 to preserve trailing empty strings
boolean hasSameSite = false;
boolean isAlreadyStrict = false;

// First pass: check if SameSite exists and if it's already Strict
for (String part : parts) {
String trimmed = part.trim();
if (trimmed.regionMatches(true, 0, "samesite", 0, "samesite".length())) {
hasSameSite = true;
int eqIndex = trimmed.indexOf('=');
if (eqIndex != -1) {
String value = trimmed.substring(eqIndex + 1).trim();
if ("strict".equalsIgnoreCase(value)) {
isAlreadyStrict = true;
break;
}
}
}
}

// Early return if already Strict (no need to process)
if (hasSameSite && isAlreadyStrict) {
return cookie;
}

// Second pass: rebuild cookie, normalizing all SameSite attributes to "SameSite=Strict"
if (hasSameSite) {
StringBuilder rebuilt = new StringBuilder();
boolean first = true;
for (String part : parts) {
String trimmed = part.trim();
if (trimmed.isEmpty()) {
continue; // Skip empty parts
}
if (trimmed.regionMatches(true, 0, "samesite", 0, "samesite".length())) {
// Normalize any SameSite attribute to "SameSite=Strict"
if (!first) {
rebuilt.append("; ");
}
rebuilt.append("SameSite=Strict");
first = false;
} else {
// Preserve non-SameSite attributes (including cookie name=value)
if (!first) {
rebuilt.append("; ");
}
rebuilt.append(trimmed);
first = false;
}
}
return rebuilt.toString();
}

// 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;
}

/**
* Determines if a path matches the API version pattern (/api/v or /api/v followed by a digit).
* Package-private for testing purposes.
*
* More specific path matching: /api/v or /api/v followed by a digit matches /api/v1/, /api/v2/, etc.
* but not /api-docs, /api.json, /api/version, /api/veterinary, /api/v1abc
*
* @param path The request path to check
* @return true if path matches API version pattern, false otherwise
*/
boolean isApiVersionPath(String path) {
// More specific path matching: /api/v or /api/v followed by a digit matches /api/v1/, /api/v2/, etc.
// but not /api-docs, /api.json, /api/version, /api/veterinary, /api/v1abc
// 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.
// Use startsWith() and character check instead of regex to avoid ReDoS vulnerability
// Check if path is exactly /api/v, or starts with /api/v followed by a digit
// "/api/v" is 6 characters (indices 0-5), so index 6 is the first character after "/api/v"
return path.equals("/api/v")
|| (path.startsWith("/api/v")
&& path.length() >= 7 // At least "/api/v" (6 chars) + one digit
&& Character.isDigit(path.charAt(6)) // Character at index 6 (first char after "/api/v")
&& (path.length() == 7 || path.charAt(7) == '/')); // Next char is end-of-string or '/'
}
}
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
Loading
Loading