Skip to content

Commit e167dad

Browse files
author
euniceamoni
authored
fix(gdpr): send export email in POST body, not GET query string (#1235)
* fix(gdpr): send export email in POST body, not GET query string PII (email address) was being sent as a URL query parameter on GET /api/v1/newsletter/gdpr/export, exposing it to server access logs, browser history, and proxy logs. Changes: - frontend: newsletterGdprExport now uses POST with body: { email } instead of GET with params: { email } (client.ts) - backend: added NewsletterExportBody struct, changed handler extractor from Query to Json, updated utoipa path attribute to post, updated route registration from get() to post() (handlers.rs, main.rs) - tests: updated it.each entry to expect POST; added new test in 'GDPR export (#1156)' describe block asserting email is in the request body and absent from the URL Fixes #1156 * fix(openapi): update GDPR export spec and contract test to POST Update openapi.yaml /api/v1/newsletter/gdpr/export from GET with query parameter to POST with requestBody (EmailRequest schema), matching the handler change in handlers.rs. Update SPEC_ROUTES in openapi_contract_test.rs from GET to POST to keep the contract test in sync with the spec and runtime router.
1 parent bb83c1a commit e167dad

6 files changed

Lines changed: 42 additions & 17 deletions

File tree

frontend/src/lib/api/__tests__/client.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('API Client', () => {
3535
['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'],
3636
['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'],
3737
['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'],
38-
['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'],
38+
['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'],
3939
['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'],
4040
['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'],
4141
['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'],
@@ -629,6 +629,27 @@ describe('API Client', () => {
629629
});
630630
});
631631

632+
describe('GDPR export (#1156)', () => {
633+
it('sends email in the POST request body, not as a URL query parameter', async () => {
634+
(global.fetch as jest.Mock).mockResolvedValueOnce({
635+
ok: true,
636+
text: async () => JSON.stringify({ success: true, data: {} }),
637+
});
638+
639+
await api.newsletterGdprExport('user@example.com');
640+
641+
const [calledUrl, calledInit] = (global.fetch as jest.Mock).mock.calls[0];
642+
643+
// Email must NOT appear in the URL to prevent logging PII in access logs.
644+
expect(calledUrl).not.toContain('user@example.com');
645+
expect(calledUrl).not.toContain('email=');
646+
647+
// Email MUST be in the JSON body.
648+
expect(calledInit.method).toBe('POST');
649+
expect(JSON.parse(calledInit.body as string)).toEqual({ email: 'user@example.com' });
650+
});
651+
});
652+
632653
describe('DELETE requests', () => {
633654
it('should handle DELETE requests with body', async () => {
634655
const mockResponse = { success: true };

frontend/src/lib/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,9 @@ export const api = {
458458

459459
newsletterGdprExport: (email: string, signal?: AbortSignal) =>
460460
request<{ success: boolean; data: Record<string, unknown> }>(
461-
"GET",
461+
"POST",
462462
"/api/v1/newsletter/gdpr/export",
463-
{ params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal }
463+
{ body: { email }, cacheTags: [CacheTag.NEWSLETTER], signal }
464464
),
465465

466466
newsletterGdprDelete: (email: string, signal?: AbortSignal) =>

services/api/openapi.yaml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -387,17 +387,16 @@ paths:
387387
$ref: "#/components/responses/ApiError"
388388

389389
/api/v1/newsletter/gdpr/export:
390-
get:
390+
post:
391391
tags: [newsletter]
392392
operationId: newsletterGdprExport
393393
summary: GDPR data export for a subscriber
394-
parameters:
395-
- name: email
396-
in: query
397-
required: true
398-
schema:
399-
type: string
400-
format: email
394+
requestBody:
395+
required: true
396+
content:
397+
application/json:
398+
schema:
399+
$ref: "#/components/schemas/EmailRequest"
401400
responses:
402401
"200":
403402
description: Subscriber data

services/api/src/handlers.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ pub struct NewsletterExportQuery {
366366
pub email: String,
367367
}
368368

369+
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
370+
pub struct NewsletterExportBody {
371+
pub email: String,
372+
}
373+
369374
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
370375
pub struct NewsletterResponse {
371376
pub success: bool,
@@ -634,10 +639,10 @@ pub async fn newsletter_unsubscribe(
634639
}
635640

636641
#[utoipa::path(
637-
get,
642+
post,
638643
path = "/api/v1/newsletter/gdpr/export",
639644
tag = "newsletter",
640-
params(NewsletterExportQuery),
645+
request_body = NewsletterExportBody,
641646
responses(
642647
(status = 200, description = "GDPR data export", body = NewsletterExportResponse),
643648
(status = 400, description = "Invalid email", body = NewsletterResponse),
@@ -649,7 +654,7 @@ pub async fn newsletter_gdpr_export(
649654
State(state): State<Arc<AppState>>,
650655
headers: HeaderMap,
651656
connect_info: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
652-
Query(query): Query<NewsletterExportQuery>,
657+
Json(body): Json<NewsletterExportBody>,
653658
) -> Result<Response, ApiError> {
654659
use crate::security::extract_client_ip_cidrs;
655660
let ip = extract_client_ip_cidrs(
@@ -677,7 +682,7 @@ pub async fn newsletter_gdpr_export(
677682
.into_response());
678683
}
679684

680-
let Some(email) = normalized_email(&query.email) else {
685+
let Some(email) = normalized_email(&body.email) else {
681686
return Ok((
682687
StatusCode::BAD_REQUEST,
683688
Json(NewsletterResponse {

services/api/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ async fn main() -> anyhow::Result<()> {
388388
.route("/api/v1/newsletter/subscribe", post(handlers::newsletter_subscribe))
389389
.route("/api/v1/newsletter/confirm", get(handlers::newsletter_confirm))
390390
.route("/api/v1/newsletter/unsubscribe", get(handlers::newsletter_unsubscribe))
391-
.route("/api/v1/newsletter/gdpr/export", get(handlers::newsletter_gdpr_export))
391+
.route("/api/v1/newsletter/gdpr/export", post(handlers::newsletter_gdpr_export))
392392
.route("/api/v1/newsletter/gdpr/delete", axum::routing::delete(handlers::newsletter_gdpr_delete))
393393
.layer(middleware::from_fn(correlation::correlation_id_middleware))
394394
.layer(TraceLayer::new_for_http())

services/api/tests/openapi_contract_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ mod tests {
2727
("POST", "/api/v1/newsletter/subscribe"),
2828
("GET", "/api/v1/newsletter/confirm"),
2929
("DELETE", "/api/v1/newsletter/unsubscribe"),
30-
("GET", "/api/v1/newsletter/gdpr/export"),
30+
("POST", "/api/v1/newsletter/gdpr/export"),
3131
("DELETE", "/api/v1/newsletter/gdpr/delete"),
3232
("GET", "/api/v1/email/preview/{template_name}"),
3333
("POST", "/api/v1/email/test"),

0 commit comments

Comments
 (0)