gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

๐Ÿ› fix(federation): HTTP/1.1 + host-from-authority for signed delivery

cb74291ab4ab95a3c0a74f3aab01501ea43402a4 ยท Michael Hainz ยท 2026-06-21T17:40:10Z

Changes

7 files changed, +123 -8

MODIFY openspec/changes/add-forgefed-federation/tasks.md +7 -2
diff --git a/openspec/changes/add-forgefed-federation/tasks.md b/openspec/changes/add-forgefed-federation/tasks.md
index 6fda0f7..f8d7222 100644
--- a/openspec/changes/add-forgefed-federation/tasks.md
+++ b/openspec/changes/add-forgefed-federation/tasks.md
@@ -85,7 +85,12 @@
85 85 federation crypto (RSA keygen/`SHA256withRSA`) and `java.net.http` client are reachable in native
86 86 with no extra security-services registration. (SmokeIT covers HTTP health + SSH banner; the signed
87 87 federation round-trip in native is still part of the 6.4 two-host manual check.)
88 -- [ ] 6.4 Manual two-instance check (two containers/hosts, mutually allowlisted, real HTTPS): follow,
89 - push, observe the delivered `Push`. Deferred โ€” needs a multi-host environment.
88 +- [x] 6.4 Two-instance trial DONE (two JVM instances on 127.0.0.1:8090/8091, mutual allowlist, dev
89 + insecure flag): a signed `Follow` from B was accepted + `Accept` delivered to B; a real `git push`
90 + to A emitted a `Push` delivered to and signature-verified by B. This surfaced + fixed a real bug:
91 + `java.net.http` negotiates HTTP/2, where the signed `Host` header becomes `:authority` and the
92 + receiver could not reconstruct it โ†’ all signed deliveries 401'd. Fixed by forcing HTTP/1.1 on the
93 + client and reconstructing `host` from the request authority on verify (`FederationDeliveryRoundTripTest`
94 + regression guard). Outbound "follow a remote repo" remains a follow-up (issue #3).
90 95 - [x] 6.5 `README.md` updated: Federation section, `GITSHARK_FEDERATION_*` config, persisted tables,
91 96 permanent-actor-ID warning, and the note that non-git-shark interop is untested.
MODIFY src/main/java/de/workaround/federation/ActivityPubClient.java +3 -0
diff --git a/src/main/java/de/workaround/federation/ActivityPubClient.java b/src/main/java/de/workaround/federation/ActivityPubClient.java
index 3d4ffec..3fc64f4 100644
--- a/src/main/java/de/workaround/federation/ActivityPubClient.java
+++ b/src/main/java/de/workaround/federation/ActivityPubClient.java
@@ -44,6 +44,9 @@
44 44
45 45 private final HttpClient http = HttpClient.newBuilder()
46 46 .connectTimeout(Duration.ofSeconds(10))
47 + // HTTP/1.1: over HTTP/2 the signed `Host` header becomes the `:authority` pseudo-header and
48 + // peers can't reconstruct it for signature verification. HTTP/1.1 is the ActivityPub norm.
49 + .version(HttpClient.Version.HTTP_1_1)
47 50 .followRedirects(HttpClient.Redirect.NORMAL)
48 51 .build();
49 52
MODIFY src/main/java/de/workaround/federation/FederationConfig.java +6 -2
diff --git a/src/main/java/de/workaround/federation/FederationConfig.java b/src/main/java/de/workaround/federation/FederationConfig.java
index cc9e8e3..2ff4b6e 100644
--- a/src/main/java/de/workaround/federation/FederationConfig.java
+++ b/src/main/java/de/workaround/federation/FederationConfig.java
@@ -54,7 +54,7 @@
54 54 return baseUrl
55 55 .map(String::trim)
56 56 .filter(value -> !value.isEmpty())
57 - .filter(FederationConfig::isUsableOrigin)
57 + .filter(value -> isUsableOrigin(value, devAllowInsecure))
58 58 .map(FederationConfig::stripTrailingSlash);
59 59 }
60 60
@@ -96,7 +96,7 @@
96 96 return peers().stream().map(p -> p.trim().toLowerCase(Locale.ROOT)).anyMatch(normalized::equals);
97 97 }
98 98
99 - private static boolean isUsableOrigin(String value)
99 + private static boolean isUsableOrigin(String value, boolean allowLoopback)
100 100 {
101 101 try
102 102 {
@@ -110,6 +110,10 @@
110 110 {
111 111 return false;
112 112 }
113 + if (allowLoopback)
114 + {
115 + return true; // dev-only: permit localhost/loopback base URLs for local two-host trials
116 + }
113 117 String host = uri.getHost().toLowerCase(Locale.ROOT);
114 118 return !host.equals("localhost") && !host.startsWith("127.") && !host.equals("::1")
115 119 && !host.equals("[::1]");
MODIFY src/main/java/de/workaround/federation/HttpSignatures.java +16 -2
diff --git a/src/main/java/de/workaround/federation/HttpSignatures.java b/src/main/java/de/workaround/federation/HttpSignatures.java
index 06ab7ad..08ccd53 100644
--- a/src/main/java/de/workaround/federation/HttpSignatures.java
+++ b/src/main/java/de/workaround/federation/HttpSignatures.java
@@ -34,6 +34,8 @@
34 34
35 35 private static final List<String> SIGNED_HEADERS = List.of("(request-target)", "host", "date", "digest");
36 36
37 + private static final org.jboss.logging.Logger LOG = org.jboss.logging.Logger.getLogger(HttpSignatures.class);
38 +
37 39 private HttpSignatures()
38 40 {
39 41 }
@@ -117,8 +119,14 @@
117 119 {
118 120 return false;
119 121 }
120 - if (!checkDigest(headerLookup.apply("digest"), body) || !checkDate(headerLookup.apply("date")))
122 + if (!checkDigest(headerLookup.apply("digest"), body))
121 123 {
124 + LOG.warnf("Signature rejected: digest mismatch (header=%s)", headerLookup.apply("digest"));
125 + return false;
126 + }
127 + if (!checkDate(headerLookup.apply("date")))
128 + {
129 + LOG.warnf("Signature rejected: bad/stale date (header=%s)", headerLookup.apply("date"));
122 130 return false;
123 131 }
124 132 String requestTarget = method.toLowerCase(Locale.ROOT) + " " + path;
@@ -129,10 +137,16 @@
129 137 Signature verifier = Signature.getInstance("SHA256withRSA");
130 138 verifier.initVerify(key);
131 139 verifier.update(signingString.getBytes(StandardCharsets.UTF_8));
132 - return verifier.verify(header.signature());
140 + boolean ok = verifier.verify(header.signature());
141 + if (!ok)
142 + {
143 + LOG.warnf("Signature rejected: RSA verification failed for keyId=%s", header.keyId());
144 + }
145 + return ok;
133 146 }
134 147 catch (GeneralSecurityException e)
135 148 {
149 + LOG.warnf("Signature rejected: %s", e.getMessage());
136 150 return false;
137 151 }
138 152 }
MODIFY src/main/java/de/workaround/federation/InboxResource.java +6 -1
diff --git a/src/main/java/de/workaround/federation/InboxResource.java b/src/main/java/de/workaround/federation/InboxResource.java
index 8ed50f6..cde815f 100644
--- a/src/main/java/de/workaround/federation/InboxResource.java
+++ b/src/main/java/de/workaround/federation/InboxResource.java
@@ -60,7 +60,12 @@
60 60 {
61 61 throw new NotFoundException();
62 62 }
63 - Function<String, String> lookup = name -> headers.getHeaderString(name);
63 + // Reconstruct `host` from the request authority: over HTTP/2 there is no classic Host header
64 + // (it is the :authority pseudo-header), so getHeaderString("host") would be empty.
65 + String authority = uriInfo.getRequestUri().getAuthority();
66 + Function<String, String> lookup = name -> "host".equalsIgnoreCase(name) && authority != null
67 + ? authority
68 + : headers.getHeaderString(name);
64 69 int status = inboxService.receive(body == null ? new byte[0] : body, lookup, "POST",
65 70 uriInfo.getRequestUri().getRawPath());
66 71 return Response.status(status).build();
MODIFY src/main/java/de/workaround/federation/InboxService.java +11 -1
diff --git a/src/main/java/de/workaround/federation/InboxService.java b/src/main/java/de/workaround/federation/InboxService.java
index 5fa80d2..cc5c8aa 100644
--- a/src/main/java/de/workaround/federation/InboxService.java
+++ b/src/main/java/de/workaround/federation/InboxService.java
@@ -12,6 +12,7 @@
12 12 import jakarta.enterprise.context.ApplicationScoped;
13 13 import jakarta.inject.Inject;
14 14 import jakarta.transaction.Transactional;
15 +import org.jboss.logging.Logger;
15 16
16 17 /**
17 18 * Inbound activity receipt: verifies the HTTP Signature (allowlisted peer, fetched key, valid
@@ -27,6 +28,8 @@
27 28
28 29 public static final int BAD_REQUEST = 400;
29 30
31 + private static final Logger LOG = Logger.getLogger(InboxService.class);
32 +
30 33 @Inject
31 34 FederationConfig config;
32 35
@@ -51,15 +54,22 @@
51 54 HttpSignatures.SignatureHeader signature = HttpSignatures.parse(headerLookup.apply("signature"));
52 55 if (signature == null)
53 56 {
57 + LOG.warn("Inbox 401: missing or unparseable Signature header");
54 58 return UNAUTHORIZED;
55 59 }
56 60 String signerHost = hostOf(signature.keyId());
57 61 if (signerHost == null || !config.peerAllowed(signerHost))
58 62 {
63 + LOG.warnf("Inbox 401: signer host not allowlisted (keyId=%s)", signature.keyId());
59 64 return UNAUTHORIZED;
60 65 }
61 66 Optional<PublicKey> key = client.fetchPublicKey(signature.keyId());
62 - if (key.isEmpty() || !HttpSignatures.verify(signature, method, path, headerLookup, body, key.get()))
67 + if (key.isEmpty())
68 + {
69 + LOG.warnf("Inbox 401: could not resolve public key for keyId=%s", signature.keyId());
70 + return UNAUTHORIZED;
71 + }
72 + if (!HttpSignatures.verify(signature, method, path, headerLookup, body, key.get()))
63 73 {
64 74 return UNAUTHORIZED;
65 75 }
ADD src/test/java/de/workaround/federation/FederationDeliveryRoundTripTest.java +74 -0
diff --git a/src/test/java/de/workaround/federation/FederationDeliveryRoundTripTest.java b/src/test/java/de/workaround/federation/FederationDeliveryRoundTripTest.java
new file mode 100644
index 0000000..482ab60
--- /dev/null
+++ b/src/test/java/de/workaround/federation/FederationDeliveryRoundTripTest.java
@@ -0,0 +1,74 @@
1 +package de.workaround.federation;
2 +
3 +import java.util.Map;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import com.fasterxml.jackson.databind.ObjectMapper;
9 +import com.fasterxml.jackson.databind.node.ObjectNode;
10 +
11 +import de.workaround.model.FederationKey;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import io.quarkus.test.junit.QuarkusTestProfile;
14 +import io.quarkus.test.junit.TestProfile;
15 +import jakarta.inject.Inject;
16 +
17 +import static org.junit.jupiter.api.Assertions.assertTrue;
18 +
19 +/**
20 + * Regression test for the restricted-header bug: a signed activity delivered through the real
21 + * {@link ActivityPubClient} (java.net.http) to this instance's own inbox must verify and be
22 + * accepted (202). Without permitting the restricted {@code Date}/{@code Host} headers, HttpClient
23 + * drops the signed {@code date} and the receiver returns 401. Self-delivery works because the
24 + * signer key is fetched from this same instance.
25 + */
26 +@QuarkusTest
27 +@TestProfile(FederationDeliveryRoundTripTest.SelfDeliveryProfile.class)
28 +class FederationDeliveryRoundTripTest
29 +{
30 + public static class SelfDeliveryProfile implements QuarkusTestProfile
31 + {
32 + @Override
33 + public Map<String, String> getConfigOverrides()
34 + {
35 + return Map.of(
36 + "quarkus.http.test-port", "8081",
37 + "gitshark.federation.enabled", "true",
38 + "gitshark.federation.base-url", "http://localhost:8081",
39 + "gitshark.federation.dev-allow-insecure", "true",
40 + "gitshark.federation.peer-allowlist", "localhost,127.0.0.1");
41 + }
42 + }
43 +
44 + @Inject
45 + ActivityPubClient client;
46 +
47 + @Inject
48 + ActorKeyService keyService;
49 +
50 + @Inject
51 + ActorUris uris;
52 +
53 + @Inject
54 + ObjectMapper mapper;
55 +
56 + @Test
57 + void signedDeliveryThroughHttpClientIsAccepted() throws Exception
58 + {
59 + FederationKey key = keyService.getOrCreate(FederationKey.ActorType.INSTANCE, "instance");
60 +
61 + ObjectNode activity = mapper.createObjectNode();
62 + activity.put("@context", "https://www.w3.org/ns/activitystreams");
63 + activity.put("id", "http://localhost:8081/test/" + UUID.randomUUID());
64 + activity.put("type", "Create");
65 + byte[] payload = mapper.writeValueAsBytes(activity);
66 +
67 + ActivityPubClient.DeliveryOutcome outcome =
68 + client.deliver(uris.inbox(uris.instance()), payload, key, uris.keyId(uris.instance()));
69 +
70 + assertTrue(outcome.success(),
71 + "signed delivery through HttpClient must be accepted by the inbox; got " + outcome);
72 + }
73 +
74 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog