Просмотр исходного кода

Security hardening (maziggy/bambuddy-security #N)

maziggy 1 день назад
Родитель
Сommit
57ed8253ea

+ 11 - 5
backend/app/api/routes/github_backup.py

@@ -24,8 +24,8 @@ from backend.app.schemas.github_backup import (
     GitHubRestorePreview,
     GitHubRestoreRequest,
     GitHubRestoreResponse,
+    GitHubTestConnectionRequest,
     GitHubTestConnectionResponse,
-    ProviderType,
     RestoreCategory,
 )
 from backend.app.services.github_backup import github_backup_service
@@ -345,13 +345,19 @@ async def delete_config(
 
 @router.post("/test", response_model=GitHubTestConnectionResponse)
 async def test_connection(
-    repo_url: str = Query(..., description="Repository URL"),
-    token: str = Query(..., description="Personal Access Token"),
-    provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
+    body: GitHubTestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Test Git provider connection with provided credentials."""
-    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    # Checked like a saved repository URL (see _enforce_private_repo).
+    try:
+        assert_safe_lan_service_url(body.repo_url, label="Repository URL")
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+    result = await github_backup_service.test_connection(body.repo_url, body.token, provider=body.provider)
     return GitHubTestConnectionResponse(**result)
 
 

+ 8 - 0
backend/app/schemas/github_backup.py

@@ -186,6 +186,14 @@ class GitHubBackupStatus(BaseModel):
     next_scheduled_run: datetime | None
 
 
+class GitHubTestConnectionRequest(BaseModel):
+    """Credentials to test before they are saved."""
+
+    repo_url: str = Field(..., min_length=1, max_length=500, description="Repository URL")
+    token: str = Field(..., min_length=1, description="Personal Access Token")
+    provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git provider key")
+
+
 class GitHubTestConnectionResponse(BaseModel):
     """Schema for test connection response."""
 

+ 52 - 0
backend/tests/integration/test_github_backup_api.py

@@ -566,3 +566,55 @@ class TestGitHubBackupTriggerAPI:
         response = await async_client.post("/api/v1/github-backup/run")
         assert response.status_code == 400
         assert "disabled" in response.json()["detail"].lower()
+
+
+class TestConnectionTestCredentials:
+    """POST /github-backup/test takes the credentials in a JSON body."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credentials_in_the_body(self, async_client: AsyncClient, _mock_private_repo_check):
+        response = await async_client.post(
+            "/api/v1/github-backup/test",
+            json={"repo_url": "https://github.com/test/repo", "token": "ghp_body_value", "provider": "gitea"},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["success"] is True
+        _mock_private_repo_check.assert_awaited_once_with(
+            "https://github.com/test/repo", "ghp_body_value", provider="gitea"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_provider_defaults_to_github(self, async_client: AsyncClient, _mock_private_repo_check):
+        response = await async_client.post(
+            "/api/v1/github-backup/test",
+            json={"repo_url": "https://github.com/test/repo", "token": "ghp_body_value"},
+        )
+
+        assert response.status_code == 200, response.text
+        assert _mock_private_repo_check.await_args.kwargs["provider"] == "github"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_credentials_in_the_query_are_not_used(self, async_client: AsyncClient, _mock_private_repo_check):
+        response = await async_client.post(
+            "/api/v1/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_query_value"
+        )
+
+        assert response.status_code == 422
+        _mock_private_repo_check.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("repo_url", ["file:///etc/passwd", "http://169.254.169.254/org/repo"])
+    async def test_a_refused_repository_url_is_not_contacted(
+        self, async_client: AsyncClient, _mock_private_repo_check, repo_url
+    ):
+        response = await async_client.post(
+            "/api/v1/github-backup/test", json={"repo_url": repo_url, "token": "ghp_body_value", "provider": "gitea"}
+        )
+
+        assert response.status_code == 422
+        _mock_private_repo_check.assert_not_awaited()

+ 1 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -645,6 +645,7 @@ GUARDED_BODY_URLS = {
     # github_backup._enforce_private_repo, which both POST and PATCH funnel through.
     ("GitHubBackupConfigCreate", "repository_url"),
     ("GitHubBackupConfigUpdate", "repository_url"),
+    ("GitHubTestConnectionRequest", "repo_url"),  # same guard, in the test route
     # SmartPlug{Create,Update} persist these; every read goes back out through
     # RESTSmartPlugService._send_request, which applies the same guard.
     ("SmartPlugCreate", "rest_on_url"),

+ 31 - 8
frontend/src/__tests__/api/githubBackupApi.test.ts

@@ -5,6 +5,7 @@
 import { describe, it, expect } from 'vitest';
 import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
+import { api } from '../../api/client';
 import type {
   GitHubBackupConfig,
   GitHubBackupStatus,
@@ -285,10 +286,11 @@ describe('GitHub Backup API Endpoints', () => {
       })
     );
 
-    const response = await fetch(
-      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,
-      { method: 'POST' }
-    );
+    const response = await fetch(`${API_BASE}/github-backup/test`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ repo_url: 'https://github.com/test/repo', token: 'ghp_test' }),
+    });
     const data = await response.json();
     expect(data.success).toBe(true);
     expect(data.repo_name).toBe('test/repo');
@@ -306,12 +308,33 @@ describe('GitHub Backup API Endpoints', () => {
       })
     );
 
-    const response = await fetch(
-      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,
-      { method: 'POST' }
-    );
+    const response = await fetch(`${API_BASE}/github-backup/test`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ repo_url: 'https://github.com/test/repo', token: 'invalid' }),
+    });
     const data = await response.json();
     expect(data.success).toBe(false);
     expect(data.message).toBe('Authentication failed');
   });
+
+  it('testGitHubConnection carries the credentials in the request body, not the URL', async () => {
+    let seen: { url: string; body: unknown } | null = null;
+    server.use(
+      http.post('*/api/v1/github-backup/test', async ({ request }) => {
+        seen = { url: request.url, body: await request.json() };
+        return HttpResponse.json({ success: true, message: 'ok', is_private: true });
+      })
+    );
+
+    await api.testGitHubConnection('https://github.com/test/backup-repo', 'ghp_typed_value', 'gitea');
+
+    expect(seen!.url).not.toContain('ghp_typed_value');
+    expect(new URL(seen!.url).search).toBe('');
+    expect(seen!.body).toEqual({
+      repo_url: 'https://github.com/test/backup-repo',
+      token: 'ghp_typed_value',
+      provider: 'gitea',
+    });
+  });
 });

+ 4 - 4
frontend/src/api/client.ts

@@ -8182,10 +8182,10 @@ export const api = {
     request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
 
   testGitHubConnection: (repoUrl: string, token: string, provider: GitProviderType = 'github') =>
-    request<GitHubTestConnectionResponse>(
-      `/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}`,
-      { method: 'POST' }
-    ),
+    request<GitHubTestConnectionResponse>('/github-backup/test', {
+      method: 'POST',
+      body: JSON.stringify({ repo_url: repoUrl, token, provider }),
+    }),
 
   testGitHubStoredConnection: () =>
     request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),