org.springframework.security.web.header.writers.HpkpHeaderWriter Maven / Gradle / Ivy
/*
* Copyright 2002-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.header.writers;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.web.header.HeaderWriter;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
/**
* Provides support for HTTP Public Key
* Pinning (HPKP).
*
*
* Since Section 4.1 states
* that a value on the order of 60 days (5,184,000 seconds) may be considered a good
* balance, we use this value as the default. This can be customized using
* {@link #setMaxAgeInSeconds(long)}.
*
*
*
* Because Appendix B
* recommends that operators should first deploy public key pinning by using the
* report-only mode, we opted to use this mode as default. This can be customized using
* {@link #setReportOnly(boolean)}.
*
*
*
* Since we need to validate a certificate chain, the "Public-Key-Pins" or
* "Public-Key-Pins-Report-Only" header will only be added when
* {@link HttpServletRequest#isSecure()} returns {@code true}.
*
*
*
* To set the pins you first need to extract the public key information from your
* certificate or key file and encode them using Base64. The following commands will help
* you extract the Base64 encoded information from a key file, a certificate signing
* request, or a certificate.
*
*
* openssl rsa -in my-key-file.key -outform der -pubout | openssl dgst -sha256 -binary | openssl enc -base64
*
* openssl req -in my-signing-request.csr -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
*
* openssl x509 -in my-certificate.crt -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
*
*
*
* The following command will extract the Base64 encoded information for a website.
*
*
* openssl s_client -servername www.example.com -connect www.example.com:443 | openssl x509 -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
*
*
*
*
* Some examples:
*
*
* Public-Key-Pins: max-age=3000;
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="
*
* Public-Key-Pins: max-age=5184000;
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="
*
* Public-Key-Pins: max-age=5184000;
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=";
* report-uri="https://example.com/pkp-report"
*
* Public-Key-Pins-Report-Only: max-age=5184000;
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=";
* report-uri="https://other.example.net/pkp-report"
*
* Public-Key-Pins: max-age=5184000;
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=";
* includeSubDomains
*
*
*
* @author Tim Ysewyn
* @author Ankur Pathak
* @since 4.1
*/
public final class HpkpHeaderWriter implements HeaderWriter {
private static final long DEFAULT_MAX_AGE_SECONDS = 5184000;
private static final String HPKP_HEADER_NAME = "Public-Key-Pins";
private static final String HPKP_RO_HEADER_NAME = "Public-Key-Pins-Report-Only";
private final Log logger = LogFactory.getLog(getClass());
private final RequestMatcher requestMatcher = new SecureRequestMatcher();
private Map pins = new LinkedHashMap<>();
private long maxAgeInSeconds;
private boolean includeSubDomains;
private boolean reportOnly;
private URI reportUri;
private String hpkpHeaderValue;
/**
* Creates a new instance
* @param maxAgeInSeconds maps to {@link #setMaxAgeInSeconds(long)}
* @param includeSubDomains maps to {@link #setIncludeSubDomains(boolean)}
* @param reportOnly maps to {@link #setReportOnly(boolean)}
*/
public HpkpHeaderWriter(long maxAgeInSeconds, boolean includeSubDomains, boolean reportOnly) {
this.maxAgeInSeconds = maxAgeInSeconds;
this.includeSubDomains = includeSubDomains;
this.reportOnly = reportOnly;
updateHpkpHeaderValue();
}
/**
* Creates a new instance
* @param maxAgeInSeconds maps to {@link #setMaxAgeInSeconds(long)}
* @param includeSubDomains maps to {@link #setIncludeSubDomains(boolean)}
*/
public HpkpHeaderWriter(long maxAgeInSeconds, boolean includeSubDomains) {
this(maxAgeInSeconds, includeSubDomains, true);
}
/**
* Creates a new instance
* @param maxAgeInSeconds maps to {@link #setMaxAgeInSeconds(long)}
*/
public HpkpHeaderWriter(long maxAgeInSeconds) {
this(maxAgeInSeconds, false);
}
/**
* Creates a new instance
*/
public HpkpHeaderWriter() {
this(DEFAULT_MAX_AGE_SECONDS);
}
@Override
public void writeHeaders(HttpServletRequest request, HttpServletResponse response) {
if (!this.requestMatcher.matches(request)) {
this.logger.debug("Not injecting HPKP header since it wasn't a secure connection");
return;
}
if (this.pins.isEmpty()) {
this.logger.debug("Not injecting HPKP header since there aren't any pins");
return;
}
String headerName = (this.reportOnly) ? HPKP_RO_HEADER_NAME : HPKP_HEADER_NAME;
if (!response.containsHeader(headerName)) {
response.setHeader(headerName, this.hpkpHeaderValue);
}
}
/**
*
* Sets the value for the pin- directive of the Public-Key-Pins header.
*
*
*
* The pin directive specifies a way for web host operators to indicate a
* cryptographic identity that should be bound to a given web host. See
* Section 2.1.1 for
* additional details.
*
*
*
* To get a pin of
*
* Public-Key-Pins: pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="
*
* Use
*
*
* Map<String, String> pins = new HashMap<String, String>();
* pins.put("d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", "sha256");
* pins.put("E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", "sha256");
*
*
* @param pins the map of base64-encoded SPKI fingerprint & cryptographic hash
* algorithm pairs.
* @throws IllegalArgumentException if pins is null
*/
public void setPins(Map pins) {
Assert.notNull(pins, "pins cannot be null");
this.pins = pins;
updateHpkpHeaderValue();
}
/**
*
* Adds a list of SHA256 hashed pins for the pin- directive of the Public-Key-Pins
* header.
*
*
*
* The pin directive specifies a way for web host operators to indicate a
* cryptographic identity that should be bound to a given web host. See
* Section 2.1.1 for
* additional details.
*
*
*
* To get a pin of
*
* Public-Key-Pins-Report-Only:
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="
*
* Use
*
* HpkpHeaderWriter hpkpHeaderWriter = new HpkpHeaderWriter();
* hpkpHeaderWriter.addSha256Pins("d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM",
* "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=");
*
* @param pins a list of base64-encoded SPKI fingerprints.
* @throws IllegalArgumentException if a pin is null
*/
public void addSha256Pins(String... pins) {
for (String pin : pins) {
Assert.notNull(pin, "pin cannot be null");
this.pins.put(pin, "sha256");
}
updateHpkpHeaderValue();
}
/**
*
* Sets the value (in seconds) for the max-age directive of the Public-Key-Pins
* header. The default is 60 days.
*
*
*
* This instructs browsers how long they should regard the host (from whom the message
* was received) as a known pinned host. See
* Section 2.1.2 for
* additional details.
*
*
*
* To get a header like
*
* Public-Key-Pins-Report-Only: max-age=2592000;
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="
*
* Use
*
* HpkpHeaderWriter hpkpHeaderWriter = new HpkpHeaderWriter();
* hpkpHeaderWriter.setMaxAgeInSeconds(TimeUnit.DAYS.toSeconds(30));
*
* @param maxAgeInSeconds the maximum amount of time (in seconds) to regard the host
* as a known pinned host. (i.e. TimeUnit.DAYS.toSeconds(30) would set this to 30
* days)
* @throws IllegalArgumentException if maxAgeInSeconds is negative
*/
public void setMaxAgeInSeconds(long maxAgeInSeconds) {
Assert.isTrue(maxAgeInSeconds > 0, () -> "maxAgeInSeconds must be non-negative. Got " + maxAgeInSeconds);
this.maxAgeInSeconds = maxAgeInSeconds;
updateHpkpHeaderValue();
}
/**
*
* If true, the pinning policy applies to this pinned host as well as any subdomains
* of the host's domain name. The default is false.
*
*
*
* See Section 2.1.3
* for additional details.
*
*
*
* To get a header like
*
* Public-Key-Pins-Report-Only: max-age=5184000;
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; includeSubDomains
*
* you should set this to true.
*
* @param includeSubDomains true to include subdomains, else false
*/
public void setIncludeSubDomains(boolean includeSubDomains) {
this.includeSubDomains = includeSubDomains;
updateHpkpHeaderValue();
}
/**
*
* To get a Public-Key-Pins header you should set this to false, otherwise the header
* will be Public-Key-Pins-Report-Only. When in report-only mode, the browser should
* not terminate the connection with the server. By default this is true.
*
*
*
* See Section 2.1 for
* additional details.
*
*
*
* To get a header like
*
* Public-Key-Pins: max-age=5184000;
* pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=";
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="
*
* you should the this to false.
*
* @param reportOnly true to report only, else false
*/
public void setReportOnly(boolean reportOnly) {
this.reportOnly = reportOnly;
}
/**
*
* Sets the URI to which the browser should report pin validation failures.
*
*
*
* See Section 2.1.4
* for additional details.
*
*
*
* To get a header like
*
* Public-Key-Pins-Report-Only: max-age=5184000;
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=";
* report-uri="https://other.example.net/pkp-report"
*
* Use
*
* HpkpHeaderWriter hpkpHeaderWriter = new HpkpHeaderWriter();
* hpkpHeaderWriter.setReportUri(new URI("https://other.example.net/pkp-report"));
*
* @param reportUri the URI where the browser should send the report to.
*/
public void setReportUri(URI reportUri) {
this.reportUri = reportUri;
updateHpkpHeaderValue();
}
/**
*
* Sets the URI to which the browser should report pin validation failures.
*
*
*
* See Section 2.1.4
* for additional details.
*
*
*
* To get a header like
*
* Public-Key-Pins-Report-Only: max-age=5184000;
* pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=";
* pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=";
* report-uri="https://other.example.net/pkp-report"
*
* Use
*
* HpkpHeaderWriter hpkpHeaderWriter = new HpkpHeaderWriter();
* hpkpHeaderWriter.setReportUri("https://other.example.net/pkp-report");
*
* @param reportUri the URI where the browser should send the report to.
* @throws IllegalArgumentException if the reportUri is not a valid URI
*/
public void setReportUri(String reportUri) {
try {
this.reportUri = new URI(reportUri);
updateHpkpHeaderValue();
}
catch (URISyntaxException ex) {
throw new IllegalArgumentException(ex);
}
}
private void updateHpkpHeaderValue() {
String headerValue = "max-age=" + this.maxAgeInSeconds;
for (Map.Entry pin : this.pins.entrySet()) {
headerValue += " ; pin-" + pin.getValue() + "=\"" + pin.getKey() + "\"";
}
if (this.reportUri != null) {
headerValue += " ; report-uri=\"" + this.reportUri.toString() + "\"";
}
if (this.includeSubDomains) {
headerValue += " ; includeSubDomains";
}
this.hpkpHeaderValue = headerValue;
}
private static final class SecureRequestMatcher implements RequestMatcher {
@Override
public boolean matches(HttpServletRequest request) {
return request.isSecure();
}
@Override
public String toString() {
return "Is Secure";
}
}
}