-
Notifications
You must be signed in to change notification settings - Fork 5k
[WEB-8103] fix: stop leaking webhook HMAC secret_key on reads (GHSA-83rj) #9382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mguptahub
wants to merge
2
commits into
preview
Choose a base branch
from
web-8103/webhook-secret-key-scope
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+122
−2
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
apps/api/plane/tests/contract/app/test_webhook_secret_key_scope_app.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| """ | ||
| Regression tests for GHSA-83rj-4282-x39v. | ||
|
|
||
| WebhookEndpoint list/retrieve/patch passed a fields= allowlist that excluded | ||
| secret_key, but DynamicBaseSerializer.__init__ discards the allowlist, so the | ||
| HMAC secret_key leaked on every webhook read. WebhookSerializer now hides | ||
| secret_key by default and only reveals it on create / secret regeneration. | ||
| """ | ||
|
|
||
| import pytest | ||
| from rest_framework import status | ||
|
|
||
| from plane.app.serializers import WebhookSerializer | ||
| from plane.db.models import Webhook | ||
|
|
||
|
|
||
| def _webhooks_url(slug, pk=None, regenerate=False): | ||
| base = f"/api/workspaces/{slug}/webhooks/" | ||
| if pk and regenerate: | ||
| return f"{base}{pk}/regenerate/" | ||
| if pk: | ||
| return f"{base}{pk}/" | ||
| return base | ||
|
|
||
|
|
||
| def _make_webhook(workspace, url="https://example.com/hook"): | ||
| return Webhook.objects.create(workspace=workspace, url=url) | ||
|
|
||
|
|
||
| @pytest.mark.contract | ||
| class TestWebhookSecretKeyScope: | ||
| @pytest.mark.django_db | ||
| def test_list_does_not_leak_secret_key(self, session_client, workspace): | ||
| _make_webhook(workspace) | ||
|
|
||
| response = session_client.get(_webhooks_url(workspace.slug)) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| payload = response.json() | ||
| assert payload, "expected at least one webhook" | ||
| assert all("secret_key" not in item for item in payload) | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_retrieve_does_not_leak_secret_key(self, session_client, workspace): | ||
| webhook = _make_webhook(workspace) | ||
|
|
||
| response = session_client.get(_webhooks_url(workspace.slug, pk=webhook.id)) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| assert "secret_key" not in response.json() | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_patch_does_not_leak_secret_key(self, session_client, workspace): | ||
| webhook = _make_webhook(workspace) | ||
|
|
||
| # Patch a non-url field so URL SSRF validation is not exercised. | ||
| response = session_client.patch( | ||
| _webhooks_url(workspace.slug, pk=webhook.id), {"is_active": False}, format="json" | ||
| ) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| assert "secret_key" not in response.json() | ||
| assert response.json()["is_active"] is False | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_regenerate_reveals_secret_key(self, session_client, workspace): | ||
| webhook = _make_webhook(workspace) | ||
| old_secret = webhook.secret_key | ||
|
|
||
| response = session_client.post(_webhooks_url(workspace.slug, pk=webhook.id, regenerate=True)) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| body = response.json() | ||
| assert "secret_key" in body | ||
| assert body["secret_key"] and body["secret_key"] != old_secret | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_create_reveals_secret_key(self, session_client, workspace, monkeypatch): | ||
| # Bypass only the network boundary (DNS/IP SSRF resolution); the domain and | ||
| # schema checks in _validate_webhook_url still run for example.com, and the | ||
| # test survives a rename of the private method. | ||
| monkeypatch.setattr("plane.app.serializers.webhook.validate_url", lambda *a, **k: None) | ||
|
|
||
| response = session_client.post( | ||
| _webhooks_url(workspace.slug), {"url": "https://example.com/hook"}, format="json" | ||
| ) | ||
|
|
||
| assert response.status_code == status.HTTP_201_CREATED | ||
| assert response.json().get("secret_key") | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_serializer_hides_secret_by_default_and_reveals_with_flag(self, workspace): | ||
| webhook = _make_webhook(workspace) | ||
|
|
||
| assert "secret_key" not in WebhookSerializer(webhook).data | ||
|
mguptahub marked this conversation as resolved.
|
||
| # An explicit fields= request must not re-open the leak either: enforcement | ||
| # lives in to_representation, not the (currently no-op) DynamicBaseSerializer | ||
| # field allowlist. Pins the enforcement point so a future _filter_fields fix | ||
| # can't silently re-expose the key. | ||
| assert "secret_key" not in WebhookSerializer(webhook, fields=("secret_key",)).data | ||
| revealed = WebhookSerializer(webhook, context={"show_secret_key": True}).data | ||
| assert revealed["secret_key"] == webhook.secret_key | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.