From 93a413256ebca10e4800fc5bbc5d9783804a52b3 Mon Sep 17 00:00:00 2001 From: Fury03 Date: Thu, 30 Jul 2026 01:09:44 +0100 Subject: [PATCH 1/2] fix(backend): validate Stellar publicKey format in user validator Closes #1098 --- backend/src/validators/user.validator.ts | 26 +++++++- backend/tests/user.validator.test.ts | 80 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 backend/tests/user.validator.test.ts diff --git a/backend/src/validators/user.validator.ts b/backend/src/validators/user.validator.ts index ce9a6c30..45bd5521 100644 --- a/backend/src/validators/user.validator.ts +++ b/backend/src/validators/user.validator.ts @@ -1,7 +1,31 @@ import { z } from 'zod'; +/** + * Stellar Ed25519 account IDs are base32 strings: a `G` version byte prefix + * followed by 55 characters from the RFC 4648 base32 alphabet, 56 in total. + * + * This is a format check only — it deliberately does not verify the trailing + * CRC16 checksum, so callers must not treat a match as proof the key exists + * or was typed correctly. + */ +export const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; + +export const STELLAR_PUBLIC_KEY_ERROR = + 'Invalid Stellar public key format: expected 56 base32 characters starting with "G"'; + +/** + * Reusable schema for a Stellar account public key. + * + * Rejects malformed keys at the validation layer so they never reach the + * controller or repository, where they would surface as opaque lookup misses + * or be stored as-is. + */ +export const stellarPublicKeySchema = z + .string({ message: 'publicKey is required and must be a string' }) + .regex(STELLAR_PUBLIC_KEY_REGEX, STELLAR_PUBLIC_KEY_ERROR); + export const registerUserSchema = z.object({ - publicKey: z.string().min(50, 'Invalid Stellar public key').regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key format'), + publicKey: stellarPublicKeySchema, }); export type RegisterUserInput = z.infer; diff --git a/backend/tests/user.validator.test.ts b/backend/tests/user.validator.test.ts new file mode 100644 index 00000000..1dea1792 --- /dev/null +++ b/backend/tests/user.validator.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { + registerUserSchema, + STELLAR_PUBLIC_KEY_ERROR, +} from '../src/validators/user.validator.js'; +import { errorHandler } from '../src/middleware/error.middleware.js'; + +vi.mock('../src/logger.js', () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +const VALID_KEY = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ'; + +describe('User Validator', () => { + describe('registerUserSchema', () => { + it('should accept a well-formed Stellar public key', () => { + const result = registerUserSchema.safeParse({ publicKey: VALID_KEY }); + expect(result.success).toBe(true); + }); + + it('should reject a malformed public key', () => { + const result = registerUserSchema.safeParse({ publicKey: 'not-a-stellar-key' }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(['publicKey']); + expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); + }); + + it.each([ + ['too short', 'GD2XP6FNWL6IWULV'], + ['too long', `${VALID_KEY}AAAA`], + ['wrong version prefix', `S${VALID_KEY.slice(1)}`], + ['lowercase characters', VALID_KEY.toLowerCase()], + ['character outside the base32 alphabet', `${VALID_KEY.slice(0, 55)}1`], + ['empty string', ''], + ['whitespace padded', ` ${VALID_KEY} `], + ])('should reject a key that is %s', (_label, publicKey) => { + const result = registerUserSchema.safeParse({ publicKey }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); + }); + + it.each([ + ['missing', undefined], + ['a number', 12345], + ['null', null], + ])('should reject a publicKey that is %s', (_label, publicKey) => { + const result = registerUserSchema.safeParse({ publicKey }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe( + 'publicKey is required and must be a string', + ); + }); + }); + + describe('error response', () => { + it('should surface a malformed key as a 400 with a descriptive message', () => { + const error = registerUserSchema.safeParse({ publicKey: 'not-a-stellar-key' }).error; + expect(error).toBeInstanceOf(ZodError); + + const res = { + headersSent: false, + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + + errorHandler(error, {} as Request, res as unknown as Response, vi.fn()); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Validation Error', + details: [{ path: 'publicKey', message: STELLAR_PUBLIC_KEY_ERROR }], + }); + }); + }); +}); From a61015718c72fe437845f75dba6eca3f9d65945a Mon Sep 17 00:00:00 2001 From: Fury03 Date: Thu, 30 Jul 2026 01:44:58 +0100 Subject: [PATCH 2/2] test(backend): assert publicKey validation without the generated Prisma client --- backend/tests/user.validator.test.ts | 106 ++++++++++++--------------- 1 file changed, 47 insertions(+), 59 deletions(-) diff --git a/backend/tests/user.validator.test.ts b/backend/tests/user.validator.test.ts index 1dea1792..9df67687 100644 --- a/backend/tests/user.validator.test.ts +++ b/backend/tests/user.validator.test.ts @@ -1,80 +1,68 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { Request, Response } from 'express'; +import { describe, it, expect } from 'vitest'; import { ZodError } from 'zod'; import { registerUserSchema, STELLAR_PUBLIC_KEY_ERROR, } from '../src/validators/user.validator.js'; -import { errorHandler } from '../src/middleware/error.middleware.js'; - -vi.mock('../src/logger.js', () => ({ - default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, -})); const VALID_KEY = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ'; describe('User Validator', () => { - describe('registerUserSchema', () => { - it('should accept a well-formed Stellar public key', () => { - const result = registerUserSchema.safeParse({ publicKey: VALID_KEY }); - expect(result.success).toBe(true); - }); + it('should accept a well-formed Stellar public key', () => { + const result = registerUserSchema.safeParse({ publicKey: VALID_KEY }); + expect(result.success).toBe(true); + }); - it('should reject a malformed public key', () => { - const result = registerUserSchema.safeParse({ publicKey: 'not-a-stellar-key' }); + it('should reject a malformed public key with a descriptive message', () => { + const result = registerUserSchema.safeParse({ publicKey: 'not-a-stellar-key' }); - expect(result.success).toBe(false); - expect(result.error?.issues[0]?.path).toEqual(['publicKey']); - expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); - }); + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(1); + expect(result.error?.issues[0]?.path).toEqual(['publicKey']); + expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); + }); - it.each([ - ['too short', 'GD2XP6FNWL6IWULV'], - ['too long', `${VALID_KEY}AAAA`], - ['wrong version prefix', `S${VALID_KEY.slice(1)}`], - ['lowercase characters', VALID_KEY.toLowerCase()], - ['character outside the base32 alphabet', `${VALID_KEY.slice(0, 55)}1`], - ['empty string', ''], - ['whitespace padded', ` ${VALID_KEY} `], - ])('should reject a key that is %s', (_label, publicKey) => { - const result = registerUserSchema.safeParse({ publicKey }); + it.each([ + ['too short', 'GD2XP6FNWL6IWULV'], + ['too long', `${VALID_KEY}AAAA`], + ['using a wrong version prefix', `S${VALID_KEY.slice(1)}`], + ['lowercase', VALID_KEY.toLowerCase()], + ['outside the base32 alphabet', `${VALID_KEY.slice(0, 55)}1`], + ['an empty string', ''], + ['whitespace padded', ` ${VALID_KEY} `], + ])('should reject a key that is %s', (_label, publicKey) => { + const result = registerUserSchema.safeParse({ publicKey }); - expect(result.success).toBe(false); - expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); - }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); + }); - it.each([ - ['missing', undefined], - ['a number', 12345], - ['null', null], - ])('should reject a publicKey that is %s', (_label, publicKey) => { - const result = registerUserSchema.safeParse({ publicKey }); + it.each([ + ['missing', undefined], + ['a number', 12345], + ['null', null], + ])('should reject a publicKey that is %s', (_label, publicKey) => { + const result = registerUserSchema.safeParse({ publicKey }); - expect(result.success).toBe(false); - expect(result.error?.issues[0]?.message).toBe( - 'publicKey is required and must be a string', - ); - }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe( + 'publicKey is required and must be a string', + ); }); - describe('error response', () => { - it('should surface a malformed key as a 400 with a descriptive message', () => { - const error = registerUserSchema.safeParse({ publicKey: 'not-a-stellar-key' }).error; - expect(error).toBeInstanceOf(ZodError); - - const res = { - headersSent: false, - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - }; + // `registerUser` calls `.parse`, so a malformed key throws before any Prisma + // access. The global error middleware turns that ZodError into a 400 carrying + // the message asserted above (see tests/error.middleware.test.ts). + it('should throw a ZodError so the request fails before the controller body runs', () => { + const parse = () => registerUserSchema.parse({ publicKey: 'not-a-stellar-key' }); - errorHandler(error, {} as Request, res as unknown as Response, vi.fn()); + expect(parse).toThrow(ZodError); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'Validation Error', - details: [{ path: 'publicKey', message: STELLAR_PUBLIC_KEY_ERROR }], - }); - }); + try { + parse(); + expect.unreachable('parse should have thrown'); + } catch (error) { + expect((error as ZodError).issues[0]?.message).toBe(STELLAR_PUBLIC_KEY_ERROR); + } }); });