diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index 5dcf3d6c..fafa5673 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -35,7 +35,7 @@ describe('API Client', () => { ['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'], ['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'], ['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'], - ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'], + ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'], ['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'], ['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'], ['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'], @@ -551,6 +551,27 @@ describe('API Client', () => { }); }); + describe('GDPR export (#1156)', () => { + it('sends email in the POST request body, not as a URL query parameter', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ success: true, data: {} }), + }); + + await api.newsletterGdprExport('user@example.com'); + + const [calledUrl, calledInit] = (global.fetch as jest.Mock).mock.calls[0]; + + // Email must NOT appear in the URL to prevent logging PII in access logs. + expect(calledUrl).not.toContain('user@example.com'); + expect(calledUrl).not.toContain('email='); + + // Email MUST be in the JSON body. + expect(calledInit.method).toBe('POST'); + expect(JSON.parse(calledInit.body as string)).toEqual({ email: 'user@example.com' }); + }); + }); + describe('DELETE requests', () => { it('should handle DELETE requests with body', async () => { const mockResponse = { success: true }; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 7c904c5c..3b99bb61 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -448,9 +448,9 @@ export const api = { newsletterGdprExport: (email: string, signal?: AbortSignal) => request<{ success: boolean; data: Record }>( - "GET", + "POST", "/api/v1/newsletter/gdpr/export", - { params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } + { body: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } ), newsletterGdprDelete: (email: string, signal?: AbortSignal) => diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 93c0f032..967e3da5 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -387,17 +387,16 @@ paths: $ref: "#/components/responses/ApiError" /api/v1/newsletter/gdpr/export: - get: + post: tags: [newsletter] operationId: newsletterGdprExport summary: GDPR data export for a subscriber - parameters: - - name: email - in: query - required: true - schema: - type: string - format: email + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailRequest" responses: "200": description: Subscriber data diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index a5894dc9..244bcc28 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -366,6 +366,11 @@ pub struct NewsletterExportQuery { pub email: String, } +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct NewsletterExportBody { + pub email: String, +} + #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct NewsletterResponse { pub success: bool, @@ -634,10 +639,10 @@ pub async fn newsletter_unsubscribe( } #[utoipa::path( - get, + post, path = "/api/v1/newsletter/gdpr/export", tag = "newsletter", - params(NewsletterExportQuery), + request_body = NewsletterExportBody, responses( (status = 200, description = "GDPR data export", body = NewsletterExportResponse), (status = 400, description = "Invalid email", body = NewsletterResponse), @@ -649,7 +654,7 @@ pub async fn newsletter_gdpr_export( State(state): State>, headers: HeaderMap, connect_info: Option>, - Query(query): Query, + Json(body): Json, ) -> Result { use crate::security::extract_client_ip_cidrs; let ip = extract_client_ip_cidrs( @@ -677,7 +682,7 @@ pub async fn newsletter_gdpr_export( .into_response()); } - let Some(email) = normalized_email(&query.email) else { + let Some(email) = normalized_email(&body.email) else { return Ok(( StatusCode::BAD_REQUEST, Json(NewsletterResponse { diff --git a/services/api/src/main.rs b/services/api/src/main.rs index ca907d6f..354e8f5b 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -388,7 +388,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/v1/newsletter/subscribe", post(handlers::newsletter_subscribe)) .route("/api/v1/newsletter/confirm", get(handlers::newsletter_confirm)) .route("/api/v1/newsletter/unsubscribe", get(handlers::newsletter_unsubscribe)) - .route("/api/v1/newsletter/gdpr/export", get(handlers::newsletter_gdpr_export)) + .route("/api/v1/newsletter/gdpr/export", post(handlers::newsletter_gdpr_export)) .route("/api/v1/newsletter/gdpr/delete", axum::routing::delete(handlers::newsletter_gdpr_delete)) .layer(middleware::from_fn(correlation::correlation_id_middleware)) .layer(TraceLayer::new_for_http()) diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs index 16e9bae9..cc4b6b85 100644 --- a/services/api/tests/openapi_contract_test.rs +++ b/services/api/tests/openapi_contract_test.rs @@ -27,7 +27,7 @@ mod tests { ("POST", "/api/v1/newsletter/subscribe"), ("GET", "/api/v1/newsletter/confirm"), ("DELETE", "/api/v1/newsletter/unsubscribe"), - ("GET", "/api/v1/newsletter/gdpr/export"), + ("POST", "/api/v1/newsletter/gdpr/export"), ("DELETE", "/api/v1/newsletter/gdpr/delete"), ("GET", "/api/v1/email/preview/{template_name}"), ("POST", "/api/v1/email/test"),