Merge "Fix signed push failing when using push options"
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateParserTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateParserTest.java
index 5db4563..118ce18 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateParserTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/PushCertificateParserTest.java
@@ -57,6 +57,7 @@
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
+import java.util.List;
import org.eclipse.jgit.errors.PackProtocolException;
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -364,6 +365,77 @@ public void testMissingPusheeField() throws Exception {
assertFalse(cert.toText().contains(PushCertificateParser.PUSHEE));
}
+ @Test
+ public void testWithPushOptions() throws Exception {
+ // Same cert as INPUT, but with push-option lines added after nonce
+ String inputWithOptions = "001ccertificate version 0.1\n"
+ + "0041pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
+ + "0024pushee git://localhost/repo.git\n"
+ + "002anonce 1433954361-bde756572d665bba81d8\n"
+ + "0024push-option label=Code-Review+2\n"
+ + "0021push-option label=Verified+1\n"
+ + "0017push-option submit\n"
+ + "0020push-option hashtag=bug fix\n"
+ + "0005\n"
+ + "00680000000000000000000000000000000000000000"
+ + " 6c2b981a177396fb47345b7df3e4d3f854c6bea7"
+ + " refs/heads/master\n"
+ + "0022-----BEGIN PGP SIGNATURE-----\n"
+ + "0016Version: GnuPG v1\n"
+ + "0005\n"
+ + "0045iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+ + "00459tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+ + "0045htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+ + "00454ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+ + "0045IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+ + "0045+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+ + "000a=XFeC\n"
+ + "0020-----END PGP SIGNATURE-----\n"
+ + "0012push-cert-end\n";
+
+ PacketLineIn pckIn = newPacketLineIn(inputWithOptions);
+ PushCertificateParser parser =
+ new PushCertificateParser(db, newEnabledConfig());
+ parser.receiveHeader(pckIn, false);
+
+ parser.addCommand(pckIn.readString());
+ assertEquals(PushCertificateParser.BEGIN_SIGNATURE, pckIn.readString());
+ parser.receiveSignature(pckIn);
+
+ PushCertificate cert = parser.build();
+ assertNotNull(cert);
+ assertEquals("0.1", cert.getVersion());
+ assertEquals("Dave Borowitz", cert.getPusherIdent().getName());
+
+ // Verify push options were parsed and are available on the certificate
+ List<String> pushOptions = cert.getPushOptions();
+ assertEquals(4, pushOptions.size());
+ assertEquals("label=Code-Review+2", pushOptions.get(0));
+ assertEquals("label=Verified+1", pushOptions.get(1));
+ assertEquals("submit", pushOptions.get(2));
+ assertEquals("hashtag=bug fix", pushOptions.get(3));
+
+ // Verify that toText() includes push options (critical for signature verification)
+ String text = cert.toText();
+ assertTrue("toText() must include push-option lines",
+ text.contains("push-option label=Code-Review+2"));
+ assertTrue("toText() must include push-option lines",
+ text.contains("push-option label=Verified+1"));
+ assertTrue("toText() must include push-option lines",
+ text.contains("push-option submit"));
+ assertTrue("toText() must include push-option lines",
+ text.contains("push-option hashtag=bug fix"));
+
+ // Verify push options appear after nonce and before empty line
+ int nonceIndex = text.indexOf("nonce");
+ int pushOptionIndex = text.indexOf("push-option");
+ int emptyLineIndex = text.indexOf("\n\n");
+ assertTrue("push-option must appear after nonce",
+ pushOptionIndex > nonceIndex);
+ assertTrue("push-option must appear before empty line separator",
+ pushOptionIndex < emptyLineIndex);
+ }
+
private static String concatPacketLines(String input, int begin, int end)
throws IOException {
StringBuilder result = new StringBuilder();
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/ReceivePackTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/ReceivePackTest.java
index 156746d..595b970 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/ReceivePackTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/ReceivePackTest.java
@@ -45,8 +45,15 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
import org.eclipse.jgit.errors.PackProtocolException;
+import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
import org.junit.Test;
/** Tests for receive-pack utilities. */
@@ -82,4 +89,62 @@ private void assertParseCommandFails(String input) {
// Expected.
}
}
+
+ @Test
+ public void testCertificatePushOptionsMismatch() throws Exception {
+ ReceiveCommand cmd1 = new ReceiveCommand(
+ ObjectId.fromString("0000000000000000000000000000000000000000"),
+ ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+ "refs/heads/master");
+ ReceiveCommand cmd2 = new ReceiveCommand(
+ ObjectId.fromString("0000000000000000000000000000000000000000"),
+ ObjectId.fromString("badc0ffebadc0ffebadc0ffebadc0ffebadc0ffe"),
+ "refs/heads/branch");
+
+ List<ReceiveCommand> commands = new ArrayList<>();
+ commands.add(cmd1);
+ commands.add(cmd2);
+
+ ReceivePack.validateCertificatePushOptions(
+ Arrays.asList("option1", "option2"),
+ Arrays.asList("option1", "different"), commands);
+
+ assertEquals(Result.REJECTED_OTHER_REASON, cmd1.getResult());
+ assertEquals(Result.REJECTED_OTHER_REASON, cmd2.getResult());
+ assertEquals(JGitText.get().pushCertificateInconsistentPushOptions,
+ cmd1.getMessage());
+ assertEquals(JGitText.get().pushCertificateInconsistentPushOptions,
+ cmd2.getMessage());
+ }
+
+ @Test
+ public void testCertificatePushOptionsMatch() throws Exception {
+ ReceiveCommand cmd = new ReceiveCommand(
+ ObjectId.fromString("0000000000000000000000000000000000000000"),
+ ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+ "refs/heads/master");
+
+ List<ReceiveCommand> commands = Collections.singletonList(cmd);
+
+ ReceivePack.validateCertificatePushOptions(
+ Arrays.asList("option1", "option2"),
+ Arrays.asList("option1", "option2"), commands);
+
+ assertEquals(Result.NOT_ATTEMPTED, cmd.getResult());
+ }
+
+ @Test
+ public void testCertificatePushOptionsNullTreatedAsEmpty() throws Exception {
+ ReceiveCommand cmd = new ReceiveCommand(
+ ObjectId.fromString("0000000000000000000000000000000000000000"),
+ ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+ "refs/heads/master");
+
+ List<ReceiveCommand> commands = Collections.singletonList(cmd);
+
+ ReceivePack.validateCertificatePushOptions(Collections.emptyList(),
+ null, commands);
+
+ assertEquals(Result.NOT_ATTEMPTED, cmd.getResult());
+ }
}
diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
index e24cba6..6158032 100644
--- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
+++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
@@ -638,6 +638,7 @@
pushCertificateInvalidFieldValue=Push certificate has missing or invalid value for {0}: {1}
pushCertificateInvalidHeader=Push certificate has invalid header format
pushCertificateInvalidSignature=Push certificate has invalid signature format
+pushCertificateInconsistentPushOptions=Push certificate push options do not match command push options
pushDefaultNothing=No refspec given and push.default=nothing; no upstream branch can be determined
pushDefaultNoUpstream=No upstream branch found for local branch ''{0}''
pushDefaultSimple=push.default=simple requires local branch name ''{0}'' to be equal to upstream tracked branch name ''{1}''
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
index 8928f47..9bd99ed 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
@@ -669,6 +669,7 @@ public static JGitText get() {
/***/ public String pushCertificateInvalidFieldValue;
/***/ public String pushCertificateInvalidHeader;
/***/ public String pushCertificateInvalidSignature;
+ /***/ public String pushCertificateInconsistentPushOptions;
/***/ public String pushDefaultNothing;
/***/ public String pushDefaultNoUpstream;
/***/ public String pushDefaultSimple;
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java
index 437abb0..b95d094 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificate.java
@@ -53,11 +53,12 @@ public enum NonceStatus {
private final String nonce;
private final NonceStatus nonceStatus;
private final List<ReceiveCommand> commands;
+ private final List<String> pushOptions;
private final String signature;
PushCertificate(String version, PushCertificateIdent pusher, String pushee,
String nonce, NonceStatus nonceStatus, List<ReceiveCommand> commands,
- String signature) {
+ List<String> pushOptions, String signature) {
if (version == null || version.isEmpty()) {
throw new IllegalArgumentException(MessageFormat.format(
JGitText.get().pushCertificateInvalidField, VERSION));
@@ -95,6 +96,7 @@ public enum NonceStatus {
this.nonce = nonce;
this.nonceStatus = nonceStatus;
this.commands = commands;
+ this.pushOptions = pushOptions;
this.signature = signature;
}
@@ -171,6 +173,16 @@ public List<ReceiveCommand> getCommands() {
}
/**
+ * Get the list of push options that were included in the push certificate.
+ *
+ * @return the push options, or an empty list if none.
+ * @since 7.5
+ */
+ public List<String> getPushOptions() {
+ return pushOptions;
+ }
+
+ /**
* Get the raw signature
*
* @return the raw signature, consisting of the lines received between the
@@ -212,8 +224,12 @@ private StringBuilder toStringBuilder() {
if (pushee != null) {
sb.append(PUSHEE).append(' ').append(pushee).append('\n');
}
- sb.append(NONCE).append(' ').append(nonce).append('\n')
- .append('\n');
+ sb.append(NONCE).append(' ').append(nonce).append('\n');
+ for (String option : pushOptions) {
+ sb.append(PushCertificateParser.PUSH_OPTION).append(' ')
+ .append(option).append('\n');
+ }
+ sb.append('\n');
for (ReceiveCommand cmd : commands) {
sb.append(cmd.getOldId().name())
.append(' ').append(cmd.getNewId().name())
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java
index 463d053..c9f0dec 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/PushCertificateParser.java
@@ -47,6 +47,8 @@ public class PushCertificateParser {
static final String NONCE = "nonce"; //$NON-NLS-1$
+ static final String PUSH_OPTION = "push-option"; //$NON-NLS-1$
+
static final String END_CERT = "push-cert-end"; //$NON-NLS-1$
private static final String VERSION_0_1 = "0.1"; //$NON-NLS-1$
@@ -173,6 +175,7 @@ public static PushCertificate fromString(String str)
private final boolean enabled;
private final NonceGenerator nonceGenerator;
private final List<ReceiveCommand> commands = new ArrayList<>();
+ private final List<String> pushOptions = new ArrayList<>();
/**
* <p>Constructor for PushCertificateParser.</p>
@@ -251,7 +254,8 @@ public PushCertificate build() throws IOException {
}
try {
return new PushCertificate(version, pusher, pushee, receivedNonce,
- nonceStatus, Collections.unmodifiableList(commands), signature);
+ nonceStatus, Collections.unmodifiableList(commands),
+ Collections.unmodifiableList(pushOptions), signature);
} catch (IllegalArgumentException e) {
throw new IOException(e.getMessage(), e);
}
@@ -368,10 +372,16 @@ private void receiveHeader(StringReader reader, boolean stateless)
? nonceGenerator.verify(
receivedNonce, sentNonce(), db, stateless, nonceSlopLimit)
: NonceStatus.UNSOLICITED;
- // An empty line.
- if (!reader.read().isEmpty()) {
- throw new PackProtocolException(
- JGitText.get().pushCertificateInvalidHeader);
+ // Read push-option lines (if any) before the empty line separator
+ String line;
+ while (!(line = reader.read()).isEmpty()) {
+ if (line.startsWith(PUSH_OPTION + " ")) {
+ pushOptions.add(parseHeader(line, PUSH_OPTION));
+ } else {
+ // Not a push-option, should be empty line
+ throw new PackProtocolException(
+ JGitText.get().pushCertificateInvalidHeader);
+ }
}
} catch (EOFException eof) {
throw new PackProtocolException(
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java
index 6f211e0..3d9c81c 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java
@@ -40,6 +40,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@@ -1375,6 +1376,11 @@ private void recvCommands() throws IOException {
if (hasCommands()) {
readPostCommands(pck);
}
+ // Verify that push options in the certificate match the post-command ones.
+ if (pushCert != null) {
+ validateCertificatePushOptions(pushCert.getPushOptions(),
+ pushOptions, commands);
+ }
} catch (Throwable t) {
discardCommands();
throw t;
@@ -1406,6 +1412,35 @@ private void parseShallow(String idStr) throws PackProtocolException {
}
/**
+ * Validates that push options in the certificate match the post-command
+ * push options. If they don't match, marks all commands as rejected.
+ * <p>
+ * This prevents tampering with signed push certificates by ensuring the
+ * options that were signed match exactly the options sent after the
+ * commands.
+ *
+ * @param certPushOptions
+ * the push options from the certificate
+ * @param postCommandPushOptions
+ * the push options sent after commands (may be null)
+ * @param commands
+ * the list of commands to reject if validation fails
+ */
+ static void validateCertificatePushOptions(List<String> certPushOptions,
+ List<String> postCommandPushOptions, List<ReceiveCommand> commands) {
+ List<String> postOptions = postCommandPushOptions == null
+ ? Collections.emptyList()
+ : postCommandPushOptions;
+ if (!Objects.equals(certPushOptions, postOptions)) {
+ // Mark all commands as rejected like in C Git.
+ for (ReceiveCommand cmd : commands) {
+ cmd.setResult(Result.REJECTED_OTHER_REASON,
+ JGitText.get().pushCertificateInconsistentPushOptions);
+ }
+ }
+ }
+
+ /**
* @param in
* request stream.
* @throws IOException