relay: take the Hermes route from the request path
Renames the subscription to gitea-pr-comments (it handles one event; the old
gitea-events name promised more than it delivered) and drops --deliver.
Rather than move the hardcoded route from one constant to another, the relay
now reads it from the request path: POST /gitea/<route> forwards to
<base>/webhooks/<route>. The route name was the last thing tying this service
to a specific subscription, so a second Hermes route is now a `hermes webhook
subscribe <name>` plus a Gitea hook at /gitea/<name>, with no relay change --
previously it would also have needed a second relay URL baked in here.
The path segment is interpolated into an outbound URL, so it is validated
against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ and refused rather than sanitised
when it does not match. The path is matched raw and never URL-decoded, so
percent-encoded separators fail the charset check instead of surviving it;
requiring an alphanumeric first character also rejects "." and "..". Without
this, POST /gitea/..%2fadmin would let anything that can reach the relay
steer it at other Hermes endpoints. Tests cover traversal, encoded traversal,
embedded slashes, leading dot/dash, and the length bound, and assert nothing
reaches the stub Hermes in any of those cases.
Dropping --deliver leaves it at its default of `log`. The prompt tells her to
answer in the pull request, so the PR comment is the delivery and a Telegram
copy would only duplicate it; this also removes the hardcoded chat id that
was a third copy of TELEGRAM_HOME_CHANNEL.
Provisioning retires the pre-rename hook by its EXACT old URL rather than by
"points at the relay". Now that sibling hooks for other routes are the
intended pattern, a prefix match would delete them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
@@ -39,7 +39,8 @@ open(os.path.join(creds, "webhook_secret"), "wb").write(SECRET + b"\n")
|
||||
|
||||
env = {**os.environ, "CREDENTIALS_DIRECTORY": creds, "LISTEN_HOST": "127.0.0.1",
|
||||
"LISTEN_PORT": str(RELAY_PORT),
|
||||
"HERMES_WEBHOOK_URL": f"http://127.0.0.1:{HERMES_PORT}/webhooks/gitea-events"}
|
||||
"HERMES_WEBHOOK_BASE": f"http://127.0.0.1:{HERMES_PORT}/webhooks",
|
||||
"DEFAULT_ROUTE": "gitea-pr-comments"}
|
||||
RELAY = str(pathlib.Path(__file__).with_name('gitea-hermes-webhook-relay.py'))
|
||||
relay = subprocess.Popen([sys.executable, RELAY],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
@@ -49,8 +50,8 @@ for _ in range(50):
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health", timeout=1); break
|
||||
except Exception: time.sleep(0.1)
|
||||
|
||||
def post(body, headers):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}/gitea", data=body,
|
||||
def post(body, headers, path="/gitea"):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}{path}", data=body,
|
||||
headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r: return r.status, json.load(r)
|
||||
@@ -90,7 +91,8 @@ check("signature still valid over forwarded body",
|
||||
fwd["headers"]["x-hub-signature-256"].removeprefix("sha256="),
|
||||
hmac.new(SECRET, fwd["body"], hashlib.sha256).hexdigest()))
|
||||
check("delivery id propagated", fwd["headers"].get("x-request-id") == "abc-123")
|
||||
check("path preserved", fwd["path"] == "/webhooks/gitea-events")
|
||||
check("bare /gitea uses DEFAULT_ROUTE", fwd["path"] == "/webhooks/gitea-pr-comments",
|
||||
f"got {fwd['path']}")
|
||||
|
||||
# 3. gitea-only signature header (no X-Hub-Signature-256)
|
||||
received.clear()
|
||||
@@ -120,6 +122,38 @@ check("normal-size body still ok", st == 200)
|
||||
st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig})
|
||||
check("POST /gitea ok baseline", st == 200)
|
||||
|
||||
# 7. route travels in the path: /gitea/<route> -> /webhooks/<route>
|
||||
hdrs = {"Content-Type": "application/json", "X-Gitea-Event": "push",
|
||||
"X-Hub-Signature-256": "sha256=" + sig}
|
||||
for route in ("gitea-pr-comments", "some-other_route.v2", "a"):
|
||||
received.clear()
|
||||
st, resp = post(payload, hdrs, path=f"/gitea/{route}")
|
||||
check(f"route {route!r} forwarded to /webhooks/{route}",
|
||||
st == 200 and received and received[0]["path"] == f"/webhooks/{route}",
|
||||
f"status={st} path={received[0]['path'] if received else None}")
|
||||
check(f"route {route!r} echoed in response", resp.get("route") == route, f"got {resp}")
|
||||
|
||||
# 8. route validation — these must never reach Hermes at all
|
||||
for bad, label in [
|
||||
("../admin", "parent-dir traversal"),
|
||||
("..%2fadmin", "encoded traversal"),
|
||||
("..", "bare .."),
|
||||
(".", "bare ."),
|
||||
(".hidden", "leading dot"),
|
||||
("-dash", "leading dash"),
|
||||
("route%20name", "percent-encoded space"),
|
||||
("route/extra", "embedded slash"),
|
||||
("x" * 65, "over length limit"),
|
||||
]:
|
||||
received.clear()
|
||||
st, _ = post(payload, hdrs, path=f"/gitea/{bad}")
|
||||
check(f"rejects {label}", st == 404 and not received,
|
||||
f"status={st} forwarded={len(received)}")
|
||||
|
||||
received.clear()
|
||||
st, _ = post(payload, hdrs, path="/webhooks/gitea-pr-comments")
|
||||
check("rejects non-/gitea prefix", st == 404 and not received, f"status={st}")
|
||||
|
||||
relay.terminate(); relay.wait(timeout=5); hermes.shutdown()
|
||||
print()
|
||||
print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user