From 09e06a159acd91bb5f615b2e0082f01dac3081dd Mon Sep 17 00:00:00 2001
From: rubenwardy
Date: Sat, 22 Jun 2024 11:42:51 +0100
Subject: [PATCH] Fix VCS webhooks assuming repo URLs are unique
Fixes #264
---
app/blueprints/gitlab/__init__.py | 86 -----------
app/blueprints/users/claim.py | 2 +-
app/blueprints/vcs/__init__.py | 22 +++
app/blueprints/vcs/common.py | 42 +++++
.../{github/__init__.py => vcs/github.py} | 146 ++++++++----------
app/blueprints/vcs/gitlab.py | 81 ++++++++++
app/templates/users/account.html | 4 +-
app/templates/users/login.html | 2 +-
8 files changed, 216 insertions(+), 169 deletions(-)
delete mode 100644 app/blueprints/gitlab/__init__.py
create mode 100644 app/blueprints/vcs/__init__.py
create mode 100644 app/blueprints/vcs/common.py
rename app/blueprints/{github/__init__.py => vcs/github.py} (64%)
create mode 100644 app/blueprints/vcs/gitlab.py
diff --git a/app/blueprints/gitlab/__init__.py b/app/blueprints/gitlab/__init__.py
deleted file mode 100644
index daf1541a..00000000
--- a/app/blueprints/gitlab/__init__.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# ContentDB
-# Copyright (C) 2020 rubenwardy
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-import datetime
-
-from flask import Blueprint, request, jsonify
-
-bp = Blueprint("gitlab", __name__)
-
-from app import csrf
-from app.models import Package, APIToken, Permission, PackageState
-from app.blueprints.api.support import error, api_create_vcs_release
-
-
-def webhook_impl():
- json = request.json
-
- # Get package
- gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
- package = Package.query.filter(
- Package.repo.ilike("%{}%".format(gitlab_url)), Package.state != PackageState.DELETED).first()
- if package is None:
- return error(400,
- "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
-
- # Get all tokens for package
- secret = request.headers.get("X-Gitlab-Token")
- if secret is None:
- return error(403, "Token required")
-
- token = APIToken.query.filter_by(access_token=secret).first()
- if token is None:
- return error(403, "Invalid authentication")
-
- if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
- return error(403, "You do not have the permission to approve releases")
-
- #
- # Check event
- #
-
- event = json["event_name"]
- if event == "push":
- ref = json["after"]
- title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
- branch = json["ref"].replace("refs/heads/", "")
- if branch not in ["master", "main"]:
- return jsonify({"success": False,
- "message": "Webhook ignored, as it's not on the master/main branch"})
-
- elif event == "tag_push":
- ref = json["ref"]
- title = ref.replace("refs/tags/", "")
- else:
- return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
- .format(event or "null"))
-
- #
- # Perform release
- #
-
- if package.releases.filter_by(commit_hash=ref).count() > 0:
- return
-
- return api_create_vcs_release(token, package, title, ref, reason="Webhook")
-
-
-@bp.route("/gitlab/webhook/", methods=["POST"])
-@csrf.exempt
-def webhook():
- try:
- return webhook_impl()
- except KeyError as err:
- return error(400, "Missing field: {}".format(err.args[0]))
diff --git a/app/blueprints/users/claim.py b/app/blueprints/users/claim.py
index 10a8b8ae..9d53b53e 100644
--- a/app/blueprints/users/claim.py
+++ b/app/blueprints/users/claim.py
@@ -50,7 +50,7 @@ def claim_forums():
flash(gettext("Unable to get GitHub username for user"), "danger")
return redirect(url_for("users.claim_forums", username=username))
else:
- return redirect(url_for("github.start"))
+ return redirect(url_for("vcs.github_start"))
if "forum_token" in session:
token = session["forum_token"]
diff --git a/app/blueprints/vcs/__init__.py b/app/blueprints/vcs/__init__.py
new file mode 100644
index 00000000..619b1e75
--- /dev/null
+++ b/app/blueprints/vcs/__init__.py
@@ -0,0 +1,22 @@
+# ContentDB
+# Copyright (C) 2024 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from flask import Blueprint
+
+bp = Blueprint("vcs", __name__)
+
+from . import github, gitlab
diff --git a/app/blueprints/vcs/common.py b/app/blueprints/vcs/common.py
new file mode 100644
index 00000000..1499ebbd
--- /dev/null
+++ b/app/blueprints/vcs/common.py
@@ -0,0 +1,42 @@
+# ContentDB
+# Copyright (C) 2024 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+
+from app.blueprints.api.support import error
+from app.models import Package, APIToken, Permission, PackageState
+
+
+def get_packages_for_vcs_and_token(token: APIToken, repo_url: str) -> list[Package]:
+ if token.package:
+ packages = [token.package]
+ if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):
+ return error(403, "You do not have the permission to approve releases")
+
+ actual_repo_url: str = token.package.repo or ""
+ if repo_url not in actual_repo_url.lower():
+ return error(400, "Repo URL does not match the API token's package")
+ else:
+ # Get package
+ packages = Package.query.filter(
+ Package.repo.ilike("%{}%".format(repo_url)), Package.state != PackageState.DELETED).all()
+ if len(packages) == 0:
+ return error(400,
+ "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(repo_url))
+ packages = [x for x in packages if x.check_perm(token.owner, Permission.APPROVE_RELEASE)]
+ if len(packages) == 0:
+ return error(403, "You do not have the permission to approve releases")
+
+ return packages
diff --git a/app/blueprints/github/__init__.py b/app/blueprints/vcs/github.py
similarity index 64%
rename from app/blueprints/github/__init__.py
rename to app/blueprints/vcs/github.py
index ed36d381..059264e0 100644
--- a/app/blueprints/github/__init__.py
+++ b/app/blueprints/vcs/github.py
@@ -1,5 +1,5 @@
# ContentDB
-# Copyright (C) 2018-21 rubenwardy
+# Copyright (C) 2018-24 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -15,33 +15,35 @@
# along with this program. If not, see .
import datetime
+import hmac
-from flask import Blueprint, abort, Response
-from flask_babel import gettext
-from app.logic.users import create_user
+import requests
+from flask import abort, Response
from flask import redirect, url_for, request, flash, jsonify, current_app
+from flask_babel import gettext
from flask_login import current_user
-from sqlalchemy import or_, and_
-from app import github, csrf
-from app.models import db, User, APIToken, Package, Permission, AuditSeverity, PackageState
-from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
-from app.blueprints.api.support import error, api_create_vcs_release
-import hmac, requests
-bp = Blueprint("github", __name__)
+from app import github, csrf
+from app.blueprints.api.support import error, api_create_vcs_release
+from app.logic.users import create_user
+from app.models import db, User, APIToken, AuditSeverity
+from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
+
+from . import bp
+from .common import get_packages_for_vcs_and_token
@bp.route("/github/start/")
-def start():
+def github_start():
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
- return github.authorize("", redirect_uri=abs_url_for("github.callback", next=next))
+ return github.authorize("", redirect_uri=abs_url_for("vcs.github_callback", next=next))
@bp.route("/github/view/")
-def view_permissions():
+def github_view_permissions():
url = "https://github.com/settings/connections/applications/" + \
current_app.config["GITHUB_CLIENT_ID"]
return redirect(url)
@@ -49,7 +51,7 @@ def view_permissions():
@bp.route("/github/callback/")
@github.authorized_handler
-def callback(oauth_token):
+def github_callback(oauth_token):
if oauth_token is None:
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
@@ -125,85 +127,71 @@ def callback(oauth_token):
return ret
+def _find_api_token(header_signature: str) -> APIToken:
+ sha_name, signature = header_signature.split('=')
+ if sha_name != 'sha1':
+ error(403, "Expected SHA1 payload signature")
+
+ for token in APIToken.query.all():
+ mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
+
+ if hmac.compare_digest(str(mac.hexdigest()), signature):
+ return token
+
+ error(401, "Invalid authentication, couldn't validate API token")
+
+
@bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt
-def webhook():
+def github_webhook():
json = request.json
- # Get package
- github_url = "github.com/" + json["repository"]["full_name"]
- package = Package.query.filter(
- Package.repo.ilike("%{}%".format(github_url)), Package.state != PackageState.DELETED).first()
- if package is None:
- return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
-
- # Get all tokens for package
- tokens_query = APIToken.query.filter(or_(APIToken.package==package,
- and_(APIToken.package==None, APIToken.owner==package.author)))
-
- possible_tokens = tokens_query.all()
- actual_token = None
-
- #
- # Check signature
- #
-
header_signature = request.headers.get('X-Hub-Signature')
if header_signature is None:
return error(403, "Expected payload signature")
- sha_name, signature = header_signature.split('=')
- if sha_name != 'sha1':
- return error(403, "Expected SHA1 payload signature")
+ token = _find_api_token(header_signature)
+ packages = get_packages_for_vcs_and_token(token, "github.com/" + json["repository"]["full_name"])
- for token in possible_tokens:
- mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
+ for package in packages:
+ #
+ # Check event
+ #
+ event = request.headers.get("X-GitHub-Event")
+ if event == "push":
+ ref = json["after"]
+ title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
+ branch = json["ref"].replace("refs/heads/", "")
+ if branch not in [ "master", "main" ]:
+ continue
- if hmac.compare_digest(str(mac.hexdigest()), signature):
- actual_token = token
- break
-
- if actual_token is None:
- return error(403, "Invalid authentication, couldn't validate API token")
-
- if not package.check_perm(actual_token.owner, Permission.APPROVE_RELEASE):
- return error(403, "You do not have the permission to approve releases")
-
- #
- # Check event
- #
-
- event = request.headers.get("X-GitHub-Event")
- if event == "push":
- ref = json["after"]
- title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
- branch = json["ref"].replace("refs/heads/", "")
- if branch not in [ "master", "main" ]:
- return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
-
- elif event == "create":
- ref_type = json.get("ref_type")
- if ref_type != "tag":
- return jsonify({
+ elif event == "create":
+ ref_type = json.get("ref_type")
+ if ref_type != "tag":
+ return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
- })
+ })
- ref = json["ref"]
- title = ref
+ ref = json["ref"]
+ title = ref
- elif event == "ping":
- return jsonify({ "success": True, "message": "Ping successful" })
+ elif event == "ping":
+ return jsonify({"success": True, "message": "Ping successful"})
- else:
- return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
- .format(event or "null"))
+ else:
+ return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
+ .format(event or "null"))
- #
- # Perform release
- #
+ #
+ # Perform release
+ #
+ if package.releases.filter_by(commit_hash=ref).count() > 0:
+ return
- if package.releases.filter_by(commit_hash=ref).count() > 0:
- return
+ return api_create_vcs_release(token, package, title, ref, reason="Webhook")
- return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook")
+ return jsonify({
+ "success": False,
+ "message": "No release made. Either the release already exists or the event was filtered based on the branch"
+ })
diff --git a/app/blueprints/vcs/gitlab.py b/app/blueprints/vcs/gitlab.py
new file mode 100644
index 00000000..1b5c8009
--- /dev/null
+++ b/app/blueprints/vcs/gitlab.py
@@ -0,0 +1,81 @@
+# ContentDB
+# Copyright (C) 2020-24 rubenwardy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import datetime
+
+from flask import request, jsonify
+
+from app import csrf
+from app.blueprints.api.support import error, api_create_vcs_release
+from app.models import APIToken
+
+from . import bp
+from .common import get_packages_for_vcs_and_token
+
+
+def webhook_impl():
+ json = request.json
+
+ # Get all tokens for package
+ secret = request.headers.get("X-Gitlab-Token")
+ if secret is None:
+ return error(403, "Token required")
+
+ token: APIToken = APIToken.query.filter_by(access_token=secret).first()
+ if token is None:
+ return error(403, "Invalid authentication")
+
+ packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"].replace("https://", "").replace("http://", ""))
+ for package in packages:
+ #
+ # Check event
+ #
+ event = json["event_name"]
+ if event == "push":
+ ref = json["after"]
+ title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
+ branch = json["ref"].replace("refs/heads/", "")
+ if branch not in ["master", "main"]:
+ continue
+ elif event == "tag_push":
+ ref = json["ref"]
+ title = ref.replace("refs/tags/", "")
+ else:
+ return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
+ .format(event or "null"))
+
+ #
+ # Perform release
+ #
+ if package.releases.filter_by(commit_hash=ref).count() > 0:
+ continue
+
+ return api_create_vcs_release(token, package, title, ref, reason="Webhook")
+
+ return jsonify({
+ "success": False,
+ "message": "No release made. Either the release already exists or the event was filtered based on the branch"
+ })
+
+
+
+@bp.route("/gitlab/webhook/", methods=["POST"])
+@csrf.exempt
+def gitlab_webhook():
+ try:
+ return webhook_impl()
+ except KeyError as err:
+ return error(400, "Missing field: {}".format(err.args[0]))
diff --git a/app/templates/users/account.html b/app/templates/users/account.html
index 8a21a805..bb32bb9b 100644
--- a/app/templates/users/account.html
+++ b/app/templates/users/account.html
@@ -55,7 +55,7 @@
{% if user == current_user %}
-
+
{{ _("View ContentDB's GitHub Permissions") }}
{% endif %}
@@ -66,7 +66,7 @@
{% endif %}
{% elif user == current_user %}
-
+
{{ _("Link Github") }}
{% else %}
diff --git a/app/templates/users/login.html b/app/templates/users/login.html
index ffa7ed1d..04029ff1 100644
--- a/app/templates/users/login.html
+++ b/app/templates/users/login.html
@@ -25,7 +25,7 @@
-
+
{{ _("GitHub") }}