Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })
Expand Down
24 changes: 24 additions & 0 deletions backend/src/api/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
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.
// 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, {
message: 'must be a valid Stellar public key (G... address)',
});

// Strict boolean parsing (handles "true", "false", true, false)
const strictBoolean = z.preprocess((val) => {
Expand Down
53 changes: 53 additions & 0 deletions backend/src/test/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})
})

45 changes: 45 additions & 0 deletions backend/src/test/stellarAddressValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading