-
Notifications
You must be signed in to change notification settings - Fork 2
fix: add security headers to Java backend to address ZAP findings #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
e1c32f0
fix: add security headers to Java backend to address ZAP findings
DerekRoberts 0d6ffbc
feat: combine best parts from both security headers PRs
DerekRoberts 8043d9c
fix: improve path matching and add test coverage for SecurityHeadersF…
DerekRoberts 9c16bab
fix: use MultivaluedMap instead of HttpHeaders for cookie handling
DerekRoberts 4acfa5f
fix: adjust test expectations for Quarkus-specific endpoints
DerekRoberts da3b1fb
fix: address all Copilot review feedback for SecurityHeadersFilter
DerekRoberts 2969033
fix: address additional Copilot review feedback
DerekRoberts dcfcce1
fix: replace regex with string check to prevent ReDoS vulnerability
DerekRoberts a675ef3
fix: correct length check in path matching logic
DerekRoberts 054356c
fix: correct character index in path matching
DerekRoberts 273cc06
fix: address remaining Copilot review feedback
DerekRoberts 8cbcbea
fix: address additional Copilot review feedback
DerekRoberts bd69d66
fix: replace regex with string manipulation to prevent ReDoS
DerekRoberts 96accd4
fix: simplify /q/openapi test logic
DerekRoberts 720147c
docs: clarify path matching test comments
DerekRoberts fc0323a
test: add comprehensive path matching unit tests
DerekRoberts a696c9d
fix: address PR feedback for security headers
DerekRoberts cda0cd5
fix: remove Cache-Control assertion from Swagger UI test
DerekRoberts 7bc4ba8
fix: address additional PR feedback
DerekRoberts File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
261 changes: 261 additions & 0 deletions
261
backend-java/src/main/java/ca/bc/gov/nrs/api/security/SecurityHeadersFilter.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,261 @@ | ||
| 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.putSingle("X-Content-Type-Options", "nosniff"); | ||
|
|
||
| // X-Frame-Options: Prevents clickjacking attacks | ||
| // Addresses: Missing Anti-clickjacking Header [10020] | ||
| headers.putSingle("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.putSingle( | ||
| "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.putSingle("Content-Security-Policy", "default-src 'self'"); | ||
|
|
||
| // Permissions-Policy: Controls browser features | ||
| // Addresses: Permissions Policy Header Not Set [10063] | ||
| headers.putSingle( | ||
| "Permissions-Policy", | ||
| "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=()," | ||
| + "gyroscope=(), speaker-selection=()"); | ||
|
|
||
| // Referrer-Policy: Controls referrer information | ||
| headers.putSingle("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(); | ||
| if (!path.startsWith("/")) { | ||
| path = "/" + path; | ||
| } | ||
| 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 or Lax, replaces with Strict. | ||
| */ | ||
| 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) | ||
| // Cookie attributes are case-insensitive per RFC 6265 | ||
| String lowerCookie = cookie.toLowerCase(); | ||
| int httpOnlyIndex = lowerCookie.indexOf("; httponly"); | ||
| int secureIndex = lowerCookie.indexOf("; secure"); | ||
| int pathIndex = lowerCookie.indexOf("; path="); | ||
|
|
||
| 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 '/' | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.