mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
GHSA-6w78-q5vm-rfmh: four git deployment/webhook read endpoints checked authentication but skipped app-level authorization, letting any active user (even a foreign viewer) read cross-tenant deployment history and logs that can contain secrets from pre/post-deploy scripts. - git.deployments/<id>: resolve deployment -> app, 404/403 via app_access_tier before returning any log bytes - git.deployments/webhook/<id>: gate on _webhook_visible_to (app-linked webhooks follow app visibility; unlinked stay panel-wide, plan 29 #11) - deploy.history & deploy.webhook-logs: app_id queries require app_access_tier; global (no app_id) queries are panel-admin only Adds backend/tests/test_git_deployment_authz.py (9 persona-matrix regression tests) and bumps the test-count ratchet. Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""GHSA-6w78-q5vm-rfmh — git deployment read endpoints must enforce app-level
|
|
authorization, not just authentication.
|
|
|
|
- GET /api/v1/git/deployments/<id> (deployment logs may contain secrets)
|
|
- GET /api/v1/git/deployments/webhook/<id> (deployment metadata across tenants)
|
|
- GET /api/v1/deploy/history (global leak when app_id omitted)
|
|
- GET /api/v1/deploy/webhook-logs (global leak when app_id omitted)
|
|
|
|
A caller with no path to the linked application gets 403/404; app-scoped
|
|
queries stay open to anyone who can reach the app; global (no app_id) history
|
|
queries are restricted to panel admins.
|
|
"""
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def deployment_rbac(app, scoping_rbac):
|
|
"""One app-linked webhook + one deployment on scoping_rbac's app, plus one
|
|
unlinked webhook."""
|
|
from types import SimpleNamespace
|
|
from app import db
|
|
from app.models import GitDeployment, GitWebhook
|
|
|
|
linked = GitWebhook(
|
|
name='dep-linked', source='github',
|
|
source_repo_url='https://example.com/r.git',
|
|
secret='s', webhook_token='tok-dep-linked',
|
|
app_id=scoping_rbac.app_id,
|
|
)
|
|
unlinked = GitWebhook(
|
|
name='dep-unlinked', source='github',
|
|
source_repo_url='https://example.com/r2.git',
|
|
secret='s', webhook_token='tok-dep-unlinked',
|
|
app_id=None,
|
|
)
|
|
db.session.add_all([linked, unlinked])
|
|
db.session.commit()
|
|
|
|
deployment = GitDeployment(
|
|
app_id=scoping_rbac.app_id, webhook_id=linked.id,
|
|
version=1, commit_sha='abc123', status='success',
|
|
deploy_output='DB_PASSWORD=hunter2',
|
|
)
|
|
db.session.add(deployment)
|
|
db.session.commit()
|
|
|
|
return SimpleNamespace(
|
|
linked_id=linked.id, unlinked_id=unlinked.id,
|
|
deployment_id=deployment.id, s=scoping_rbac,
|
|
)
|
|
|
|
|
|
def test_deployment_detail_requires_app_access(client, deployment_rbac):
|
|
"""Anyone who can reach the app reads the deployment (incl. logs); a
|
|
foreign caller is denied before any log bytes are returned."""
|
|
s = deployment_rbac.s
|
|
url = f'/api/v1/git/deployments/{deployment_rbac.deployment_id}?logs=true'
|
|
for persona in ('owner', 'member', 'viewer', 'admin'):
|
|
assert client.get(url, headers=getattr(s, persona)).status_code == 200, persona
|
|
assert client.get(url, headers=s.foreign).status_code == 403
|
|
|
|
|
|
def test_deployment_detail_missing_404(client, deployment_rbac):
|
|
assert client.get('/api/v1/git/deployments/999999',
|
|
headers=deployment_rbac.s.admin).status_code == 404
|
|
|
|
|
|
def test_webhook_deployments_scoped_to_app_visibility(client, deployment_rbac):
|
|
s = deployment_rbac.s
|
|
url = f'/api/v1/git/deployments/webhook/{deployment_rbac.linked_id}'
|
|
assert client.get(url, headers=s.member).status_code == 200
|
|
assert client.get(url, headers=s.foreign).status_code == 403
|
|
|
|
|
|
def test_webhook_deployments_unlinked_stays_visible(client, deployment_rbac):
|
|
"""Unlinked webhooks remain panel-wide viewer-visible (plan 29 #11)."""
|
|
url = f'/api/v1/git/deployments/webhook/{deployment_rbac.unlinked_id}'
|
|
assert client.get(url, headers=deployment_rbac.s.foreign).status_code == 200
|
|
|
|
|
|
def test_webhook_deployments_missing_404(client, deployment_rbac):
|
|
assert client.get('/api/v1/git/deployments/webhook/999999',
|
|
headers=deployment_rbac.s.admin).status_code == 404
|
|
|
|
|
|
def test_deploy_history_app_scoped_requires_access(client, deployment_rbac):
|
|
s = deployment_rbac.s
|
|
url = f'/api/v1/deploy/history?app_id={s.app_id}'
|
|
assert client.get(url, headers=s.member).status_code == 200
|
|
assert client.get(url, headers=s.foreign).status_code == 403
|
|
|
|
|
|
def test_deploy_history_global_admin_only(client, deployment_rbac):
|
|
s = deployment_rbac.s
|
|
assert client.get('/api/v1/deploy/history', headers=s.admin).status_code == 200
|
|
for persona in ('owner', 'member', 'viewer', 'foreign'):
|
|
assert client.get('/api/v1/deploy/history',
|
|
headers=getattr(s, persona)).status_code == 403, persona
|
|
|
|
|
|
def test_webhook_logs_app_scoped_requires_access(client, deployment_rbac):
|
|
s = deployment_rbac.s
|
|
url = f'/api/v1/deploy/webhook-logs?app_id={s.app_id}'
|
|
assert client.get(url, headers=s.member).status_code == 200
|
|
assert client.get(url, headers=s.foreign).status_code == 403
|
|
|
|
|
|
def test_webhook_logs_global_admin_only(client, deployment_rbac):
|
|
s = deployment_rbac.s
|
|
assert client.get('/api/v1/deploy/webhook-logs', headers=s.admin).status_code == 200
|
|
assert client.get('/api/v1/deploy/webhook-logs', headers=s.foreign).status_code == 403
|