Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ target

# autogenerated resources
src/main/webapp/css/*
.vscode/
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import hudson.util.ListBoxModel;
import hudson.util.Secret;
import jenkins.model.Jenkins;
import org.jenkinsci.plugins.github.webhook.SignatureAlgorithm;
import org.jenkinsci.plugins.plaincredentials.StringCredentials;
import org.kohsuke.stapler.DataBoundConstructor;

Expand All @@ -25,10 +26,19 @@
public class HookSecretConfig extends AbstractDescribableImpl<HookSecretConfig> {

private String credentialsId;
private SignatureAlgorithm signatureAlgorithm;

@DataBoundConstructor
public HookSecretConfig(String credentialsId) {
public HookSecretConfig(String credentialsId, String signatureAlgorithm) {
this.credentialsId = credentialsId;
this.signatureAlgorithm = parseSignatureAlgorithm(signatureAlgorithm);
}

/**
* Legacy constructor for backwards compatibility.
*/
public HookSecretConfig(String credentialsId) {
this(credentialsId, null);
}

/**
Expand All @@ -45,15 +55,62 @@
return credentialsId;
}

/**
* Gets the signature algorithm to use for webhook validation.
*
* @return the configured signature algorithm, defaults to SHA-256
* @since 1.45.0
*/
public SignatureAlgorithm getSignatureAlgorithm() {
return signatureAlgorithm != null ? signatureAlgorithm : SignatureAlgorithm.getDefault();

Check warning on line 65 in src/main/java/org/jenkinsci/plugins/github/config/HookSecretConfig.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 65 is only partially covered, one branch is missing
}

/**
* Gets the signature algorithm name for UI binding.
*
* @return the algorithm name as string (e.g., "SHA256", "SHA1")
* @since 1.45.0
*/
public String getSignatureAlgorithmName() {
return getSignatureAlgorithm().name();
}

/**
* @param credentialsId a new ID
* @deprecated rather treat this field as final and use {@link GitHubPluginConfig#setHookSecretConfigs}
*/
@Deprecated
public void setCredentialsId(String credentialsId) {
this.credentialsId = credentialsId;
}

/**
* Ensures backwards compatibility during deserialization.
* Sets default algorithm to SHA-256 for existing configurations.
*/
private Object readResolve() {
if (signatureAlgorithm == null) {
signatureAlgorithm = SignatureAlgorithm.getDefault();
}
return this;

Check warning on line 95 in src/main/java/org/jenkinsci/plugins/github/config/HookSecretConfig.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 75-95 are not covered by tests
}

/**
* Parses signature algorithm from UI string input.
*/
private SignatureAlgorithm parseSignatureAlgorithm(String algorithmName) {
if (algorithmName == null || algorithmName.trim().isEmpty()) {

Check warning on line 102 in src/main/java/org/jenkinsci/plugins/github/config/HookSecretConfig.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 102 is only partially covered, one branch is missing
return SignatureAlgorithm.getDefault();
}

try {
return SignatureAlgorithm.valueOf(algorithmName.trim().toUpperCase());
} catch (IllegalArgumentException e) {
// Default to SHA-256 for invalid input
return SignatureAlgorithm.getDefault();
}
}

@Extension
public static class DescriptorImpl extends Descriptor<HookSecretConfig> {

Expand All @@ -62,6 +119,16 @@
return "Hook secret configuration";
}

/**
* Provides dropdown items for signature algorithm selection.
*/
public ListBoxModel doFillSignatureAlgorithmItems() {
ListBoxModel items = new ListBoxModel();
items.add("SHA-256 (Recommended)", "SHA256");
items.add("SHA-1 (Legacy)", "SHA1");
return items;
}

@SuppressWarnings("unused")
public ListBoxModel doFillCredentialsIdItems(@QueryParameter String credentialsId) {
if (!Jenkins.getInstance().hasPermission(Jenkins.MANAGE)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

private static final Logger LOGGER = LoggerFactory.getLogger(GHWebhookSignature.class);
private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1";
private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
public static final String INVALID_SIGNATURE = "COMPUTED_INVALID_SIGNATURE";

private final String payload;
Expand All @@ -47,19 +48,42 @@
/**
* Computes a RFC 2104-compliant HMAC digest using SHA1 of a payloadFrom with a given key (secret).
*
* @deprecated Use {@link #sha256()} for enhanced security
* @return HMAC digest of payloadFrom using secret as key. Will return COMPUTED_INVALID_SIGNATURE
* on any exception during computation.
*/
@Deprecated
public String sha1() {
return computeSignature(HMAC_SHA1_ALGORITHM);
}

/**
* Computes a RFC 2104-compliant HMAC digest using SHA256 of a payload with a given key (secret).
* This is the recommended method for webhook signature validation.
*
* @return HMAC digest of payload using secret as key. Will return COMPUTED_INVALID_SIGNATURE
* on any exception during computation.
* @since 1.45.0
*/
public String sha256() {
return computeSignature(HMAC_SHA256_ALGORITHM);
}
/**
* Computes HMAC signature using the specified algorithm.
*
* @param algorithm The HMAC algorithm to use (e.g., "HmacSHA1", "HmacSHA256")
* @return HMAC digest as hex string, or INVALID_SIGNATURE on error
*/
private String computeSignature(String algorithm) {
try {
final SecretKeySpec keySpec = new SecretKeySpec(secret.getPlainText().getBytes(UTF_8), HMAC_SHA1_ALGORITHM);
final Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
final SecretKeySpec keySpec = new SecretKeySpec(secret.getPlainText().getBytes(UTF_8), algorithm);
final Mac mac = Mac.getInstance(algorithm);
mac.init(keySpec);
final byte[] rawHMACBytes = mac.doFinal(payload.getBytes(UTF_8));

return Hex.encodeHexString(rawHMACBytes);
} catch (Exception e) {
LOGGER.error("", e);
LOGGER.error("Error computing {} signature", algorithm, e);

Check warning on line 86 in src/main/java/org/jenkinsci/plugins/github/webhook/GHWebhookSignature.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 86 is not covered by tests
return INVALID_SIGNATURE;
}
}
Expand All @@ -68,15 +92,44 @@
* @param digest computed signature from external place (GitHub)
*
* @return true if computed and provided signatures identical
* @deprecated Use {@link #matches(String, SignatureAlgorithm)} for explicit algorithm selection
*/
@Deprecated
public boolean matches(String digest) {
String computed = sha1();
LOGGER.trace("Signature: calculated={} provided={}", computed, digest);
return matches(digest, SignatureAlgorithm.SHA1);
}

/**
* Validates a signature using the specified algorithm.
* Uses constant-time comparison to prevent timing attacks.
*
* @param digest the signature to validate (without algorithm prefix)
* @param algorithm the signature algorithm to use
* @return true if computed and provided signatures match
* @since 1.45.0
*/
public boolean matches(String digest, SignatureAlgorithm algorithm) {
String computed;
switch (algorithm) {

Check warning on line 113 in src/main/java/org/jenkinsci/plugins/github/webhook/GHWebhookSignature.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 113 is only partially covered, one branch is missing
case SHA256:
computed = sha256();
break;
case SHA1:
computed = sha1();
break;
default:
LOGGER.warn("Unsupported signature algorithm: {}", algorithm);
return false;

Check warning on line 122 in src/main/java/org/jenkinsci/plugins/github/webhook/GHWebhookSignature.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 121-122 are not covered by tests
}

LOGGER.trace("Signature validation: algorithm={} calculated={} provided={}",
algorithm, computed, digest);
if (digest == null && computed == null) {
return true;
} else if (digest == null || computed == null) {
return false;
} else {
// Use constant-time comparison to prevent timing attacks
return MessageDigest.isEqual(computed.getBytes(UTF_8), digest.getBytes(UTF_8));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPublicKey;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import static com.cloudbees.jenkins.GitHubWebHook.X_INSTANCE_IDENTITY;
import static com.google.common.base.Charsets.UTF_8;
Expand Down Expand Up @@ -61,12 +59,23 @@
class Processor extends Interceptor {
private static final Logger LOGGER = getLogger(Processor.class);
/**
* Header key being used for the payload signatures.
* Header key being used for the legacy SHA-1 payload signatures.
*
* @see <a href=https://developer.github.com/webhooks/>Developer manual</a>
* @deprecated Use SHA-256 signatures with X-Hub-Signature-256 header
*/
@Deprecated
public static final String SIGNATURE_HEADER = "X-Hub-Signature";
private static final String SHA1_PREFIX = "sha1=";
/**
* Header key being used for the SHA-256 payload signatures (recommended).
*
* @see <a href="https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks">
* GitHub Documentation</a>
* @since 1.45.0
*/
public static final String SIGNATURE_HEADER_SHA256 = "X-Hub-Signature-256";
public static final String SHA1_PREFIX = "sha1=";
public static final String SHA256_PREFIX = "sha256=";

@Override
public Object invoke(StaplerRequest2 req, StaplerResponse2 rsp, Object instance, Object[] arguments)
Expand Down Expand Up @@ -139,25 +148,66 @@
* if a hook secret is specified in the GitHub plugin config.
* If no hook secret is configured, then the signature is ignored.
*
* Uses the configured signature algorithm (SHA-256 by default, SHA-1 for legacy support).
*
* @param req Incoming request.
* @throws InvocationTargetException if any of preconditions is not satisfied
*/
protected void shouldProvideValidSignature(StaplerRequest2 req, Object[] args)
throws InvocationTargetException {
List<Secret> secrets = GitHubPlugin.configuration().getHookSecretConfigs().stream().
map(HookSecretConfig::getHookSecret).filter(Objects::nonNull).collect(Collectors.toList());

if (!secrets.isEmpty()) {
Optional<String> signHeader = Optional.fromNullable(req.getHeader(SIGNATURE_HEADER));
isTrue(signHeader.isPresent(), "Signature was expected, but not provided");

String digest = substringAfter(signHeader.get(), SHA1_PREFIX);
LOGGER.trace("Trying to verify sign from header {}", signHeader.get());
isTrue(
secrets.stream().anyMatch(secret ->
GHWebhookSignature.webhookSignature(payloadFrom(req, args), secret).matches(digest)),
String.format("Provided signature [%s] did not match to calculated", digest)
);
List<HookSecretConfig> secretConfigs = GitHubPlugin.configuration().getHookSecretConfigs();

if (!secretConfigs.isEmpty()) {
boolean validSignatureFound = false;

for (HookSecretConfig config : secretConfigs) {
Secret secret = config.getHookSecret();
if (secret == null) {

Check warning on line 165 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 165 is only partially covered, one branch is missing
continue;

Check warning on line 166 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 166 is not covered by tests
}

SignatureAlgorithm algorithm = config.getSignatureAlgorithm();
String headerName = algorithm.getHeaderName();
String expectedPrefix = algorithm.getSignaturePrefix();

Optional<String> signHeader = Optional.fromNullable(req.getHeader(headerName));
if (!signHeader.isPresent()) {
LOGGER.debug("No signature header {} found for algorithm {}", headerName, algorithm);
continue;
}

String fullSignature = signHeader.get();
if (!fullSignature.startsWith(expectedPrefix)) {

Check warning on line 180 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 180 is only partially covered, one branch is missing
LOGGER.debug("Signature header {} does not start with expected prefix {}",
fullSignature, expectedPrefix);
continue;

Check warning on line 183 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 181-183 are not covered by tests
}

String digest = substringAfter(fullSignature, expectedPrefix);
LOGGER.trace("Verifying {} signature from header {}", algorithm, fullSignature);

boolean isValid = GHWebhookSignature.webhookSignature(payloadFrom(req, args), secret)
.matches(digest, algorithm);

if (isValid) {

Check warning on line 192 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 192 is only partially covered, one branch is missing
validSignatureFound = true;
// Log deprecation warning for SHA-1 usage
if (algorithm == SignatureAlgorithm.SHA1) {

Check warning on line 195 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 195 is only partially covered, one branch is missing
LOGGER.warn("Using deprecated SHA-1 signature validation. "

Check warning on line 196 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 196 is not covered by tests
+ "Consider upgrading webhook configuration to use SHA-256 "
+ "for enhanced security.");
} else {
LOGGER.debug("Successfully validated {} signature", algorithm);
}
break;
} else {
LOGGER.debug("Signature validation failed for algorithm {}", algorithm);
}
}

Check warning on line 206 in src/main/java/org/jenkinsci/plugins/github/webhook/RequirePostWithGHHookPayload.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 204-206 are not covered by tests

isTrue(validSignatureFound,
"No valid signature found. Ensure webhook is configured with a supported signature algorithm "
+ "(SHA-256 recommended, SHA-1 for legacy compatibility).");
}
}

Expand Down
Loading