From 0439c697ead24c0e02d512bcc072cfc2e7b86e9c Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Wed, 17 Jun 2026 15:19:29 +0100 Subject: [PATCH 1/2] fix(api): validate userId is a Stellar public key on notification routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification subscribe, preferences, and unsubscribe endpoints accepted any non-empty string as userId, allowing invalid IDs to persist permanently in notification_preferences and breaking downstream consumers (auto-rebalancer portfolio lookups, Stellar signature verification) that assume userId is a valid Stellar account. Add a reusable `isValidStellarPublicKey` helper (and `stellarAddressSchema`) backed by the Stellar SDK's `StrKey.isValidEd25519PublicKey`, which checks length, alphabet, version byte and CRC16 checksum — stricter and more correct than a bare regex. Reject malformed userIds with HTTP 400 on all three routes. Closes #7 --- backend/src/api/routes.ts | 22 +++++++++ backend/src/api/validation.ts | 17 +++++++ .../src/test/stellarAddressValidation.test.ts | 45 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 backend/src/test/stellarAddressValidation.test.ts diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 6fe5028..4e07431 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -16,6 +16,7 @@ import { writeRateLimiter } from '../middleware/rateLimit.js' import { getQueueMetrics } from '../queue/queueMetrics.js' import { blockDebugInProduction } from '../middleware/debugGate.js' import { getFeatureFlags, getPublicFeatureFlags } from '../config/featureFlags.js' +import { isValidStellarPublicKey } from './validation.js' const stellarService = new StellarService() const reflectorService = new ReflectorService() @@ -645,6 +646,13 @@ router.post('/notifications/subscribe', writeRateLimiter, idempotencyMiddleware, }) } + if (!isValidStellarPublicKey(userId)) { + return res.status(400).json({ + success: false, + error: 'userId must be a valid Stellar public key (G... address)' + }) + } + if (emailEnabled === undefined || webhookEnabled === undefined || !events) { return res.status(400).json({ success: false, @@ -724,6 +732,13 @@ router.get('/notifications/preferences', async (req, res) => { }) } + if (!isValidStellarPublicKey(userId)) { + return res.status(400).json({ + success: false, + error: 'userId must be a valid Stellar public key (G... address)' + }) + } + const preferences = notificationService.getPreferences(userId) if (!preferences) { @@ -760,6 +775,13 @@ router.delete('/notifications/unsubscribe', async (req, res) => { }) } + if (!isValidStellarPublicKey(userId)) { + return res.status(400).json({ + success: false, + error: 'userId must be a valid Stellar public key (G... address)' + }) + } + notificationService.unsubscribe(userId) logger.info('User unsubscribed from notifications', { userId }) diff --git a/backend/src/api/validation.ts b/backend/src/api/validation.ts index 1935a79..6848161 100644 --- a/backend/src/api/validation.ts +++ b/backend/src/api/validation.ts @@ -1,4 +1,21 @@ import { z } from 'zod'; +import { StrKey } from '@stellar/stellar-sdk'; + +/** + * Validates that a value is a well-formed Stellar ed25519 public key (G... address). + * Uses the Stellar SDK's StrKey check, which verifies length, alphabet, version byte + * and CRC16 checksum — far stricter and more correct than a bare regex. + */ +export function isValidStellarPublicKey(value: unknown): value is string { + return typeof value === 'string' && StrKey.isValidEd25519PublicKey(value); +} + +// Reusable schema for a Stellar public key used as a userId/address. +export const stellarAddressSchema = z + .string() + .refine(isValidStellarPublicKey, { + message: 'must be a valid Stellar public key (G... address)', + }); // Strict boolean parsing (handles "true", "false", true, false) const strictBoolean = z.preprocess((val) => { diff --git a/backend/src/test/stellarAddressValidation.test.ts b/backend/src/test/stellarAddressValidation.test.ts new file mode 100644 index 0000000..603c798 --- /dev/null +++ b/backend/src/test/stellarAddressValidation.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { Keypair } from '@stellar/stellar-sdk' +import { isValidStellarPublicKey, stellarAddressSchema } from '../api/validation.js' + +describe('isValidStellarPublicKey', () => { + it('accepts a real Stellar ed25519 public key', () => { + const pub = Keypair.random().publicKey() + expect(isValidStellarPublicKey(pub)).toBe(true) + }) + + it('rejects an arbitrary non-empty string', () => { + expect(isValidStellarPublicKey('not-a-stellar-address')).toBe(false) + }) + + it('rejects a key with an invalid checksum', () => { + const pub = Keypair.random().publicKey() + // Flip the last character to break the CRC16 checksum. + const tampered = pub.slice(0, -1) + (pub.endsWith('A') ? 'B' : 'A') + expect(isValidStellarPublicKey(tampered)).toBe(false) + }) + + it('rejects a secret seed (S...) used in place of a public key', () => { + const secret = Keypair.random().secret() + expect(isValidStellarPublicKey(secret)).toBe(false) + }) + + it('rejects non-string values', () => { + expect(isValidStellarPublicKey(undefined)).toBe(false) + expect(isValidStellarPublicKey(null)).toBe(false) + expect(isValidStellarPublicKey(12345)).toBe(false) + expect(isValidStellarPublicKey('')).toBe(false) + }) +}) + +describe('stellarAddressSchema', () => { + it('parses a valid Stellar public key', () => { + const pub = Keypair.random().publicKey() + expect(stellarAddressSchema.parse(pub)).toBe(pub) + }) + + it('fails on an invalid address', () => { + const result = stellarAddressSchema.safeParse('not-a-stellar-address') + expect(result.success).toBe(false) + }) +}) From 9c1b6f2ee8da715edf3e53bc6bc552d8e51aadb3 Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Wed, 17 Jun 2026 16:08:25 +0100 Subject: [PATCH 2/2] test(api): add HTTP-level coverage for notification userId validation Add integration tests asserting the notification subscribe, preferences, and unsubscribe routes return 400 for a malformed userId and accept a valid Stellar public key, closing the gap left by the helper/schema unit tests. Also document that stellarAddressSchema and isValidStellarPublicKey share the same validation logic, clarifying why the routes call the helper directly instead of parsing through the schema. --- backend/src/api/validation.ts | 7 ++++ backend/src/test/api.integration.test.ts | 53 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/backend/src/api/validation.ts b/backend/src/api/validation.ts index 6848161..9a85c17 100644 --- a/backend/src/api/validation.ts +++ b/backend/src/api/validation.ts @@ -11,6 +11,13 @@ export function isValidStellarPublicKey(value: unknown): value is string { } // Reusable schema for a Stellar public key used as a userId/address. +// NOTE: This is the Zod wrapper around the same `isValidStellarPublicKey` check. +// The notification routes intentionally call the helper directly (matching the +// manual field validation used by the other handlers in routes.ts) rather than +// parsing through this schema. Both paths share identical validation logic — the +// only difference is error shape (ZodError vs. the manual 400 JSON response) — so +// there is no risk of the two diverging. Use this schema where a Zod pipeline is +// already in play; use the helper for manual checks. export const stellarAddressSchema = z .string() .refine(isValidStellarPublicKey, { diff --git a/backend/src/test/api.integration.test.ts b/backend/src/test/api.integration.test.ts index d4255fa..6783816 100644 --- a/backend/src/test/api.integration.test.ts +++ b/backend/src/test/api.integration.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import express, { Express } from 'express' import cors from 'cors' import request from 'supertest' +import { Keypair } from '@stellar/stellar-sdk' import { portfolioRouter } from '../api/routes.js' import { mkdirSync, rmSync, existsSync } from 'node:fs' import { join } from 'node:path' @@ -354,3 +355,55 @@ describe.skip('Portfolio Management - GET /api/user/:address/portfolios', () => }) }) +// ─── Notification userId Validation Tests ──────────────────────────────────── + +describe('Notifications - userId must be a valid Stellar public key', () => { + const validUserId = Keypair.random().publicKey() + const invalidUserId = 'not-a-stellar-address' + + it('POST /api/notifications/subscribe rejects an invalid userId with 400', async () => { + const response = await request(app) + .post('/api/notifications/subscribe') + .send({ + userId: invalidUserId, + emailEnabled: true, + emailAddress: 'user@example.com', + webhookEnabled: false, + events: { rebalance: true, circuitBreaker: true, priceMovement: true, riskChange: true } + }) + .expect(400) + + expect(response.body.success).toBe(false) + expect(response.body.error).toMatch(/valid Stellar public key/i) + }) + + it('GET /api/notifications/preferences rejects an invalid userId with 400', async () => { + const response = await request(app) + .get('/api/notifications/preferences') + .query({ userId: invalidUserId }) + .expect(400) + + expect(response.body.success).toBe(false) + expect(response.body.error).toMatch(/valid Stellar public key/i) + }) + + it('DELETE /api/notifications/unsubscribe rejects an invalid userId with 400', async () => { + const response = await request(app) + .delete('/api/notifications/unsubscribe') + .query({ userId: invalidUserId }) + .expect(400) + + expect(response.body.success).toBe(false) + expect(response.body.error).toMatch(/valid Stellar public key/i) + }) + + it('GET /api/notifications/preferences accepts a valid Stellar public key', async () => { + const response = await request(app) + .get('/api/notifications/preferences') + .query({ userId: validUserId }) + .expect(200) + + expect(response.body.success).toBe(true) + }) +}) +