Skip to content
Open
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
59 changes: 59 additions & 0 deletions stellar-payment-platform/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,63 @@ describe('API Integration Lifecycle Suite', () => {
expect(duplicateRes.status).toBe(409);
expect(duplicateRes.body).toHaveProperty("error");
});

test('v1 /register sanitizes memo of type text — strips HTML/script tags before save', async () => {
const address = 'GBDQD3WTQ6W2VQ2W4V74UZ5WYF6B72GZ6EHD7I3L3WYH357Y4K5H3E4W';

// Mixed payload: inner safe text + dangerous tag — inner text must remain
// in the saved row, dangerous tags must be removed.
const malicious = '<b>payment</b><script>alert("xss")</script><a href="javascript:alert(1)">x</a>';

const res = await request(app)
.post('/api/v1/register')
.send({
username: 'memo_xss',
address,
memo_type: 'text',
memo: malicious,
});

expect(res.status).toBe(201);

// Saved/returned memo must not contain any angle brackets, script tags,
// javascript: scheme, etc. Inner text 'payment' is preserved.
expect(res.body.memo).not.toMatch(/[<>]/);
expect(res.body.memo).not.toMatch(/script/i);
expect(res.body.memo).not.toMatch(/javascript:/i);
expect(res.body.memo).toContain('payment');

// Lookup confirms the sanitized value is what is actually stored.
const lookup = await request(app).get(`/api/v1/lookup?address=${address}`);
expect(lookup.status).toBe(200);
expect(lookup.body).toHaveProperty('username', 'memo_xss*localhost');

// Federation response echoes the sanitized memo.
const fed = await request(app).get(`/api/v1/federation?q=GBDQD3WTQ6W2VQ2W4V74UZ5WYF6B72GZ6EHD7I3L3WYH357Y4K5H3E4W&type=id`);
expect(fed.status).toBe(200);
expect(fed.body.memo_type).toBe('text');
expect(fed.body.memo).not.toMatch(/[<>]/);
expect(fed.body.memo).not.toMatch(/script/i);
});

test('v1 /register rejects memo whose sanitization removes all content', async () => {
const address = 'GFFZF43FJB7Q5K6SWFKJQTNAXYVF7KAVN4GYJ3ZU3VZMYR5SX5QGYBS3';

const malicious = '<script>alert("xss")</script>';

const res = await request(app)
.post('/api/v1/register')
.send({
username: 'memo_xss_empty',
address,
memo_type: 'text',
memo: malicious,
});

// Sanitization emptied the memo, validateMemo rejects; no row is created.
expect(res.status).toBe(400);

const lookup = await request(app).get(`/api/v1/lookup?address=${address}`);
expect(lookup.status).toBe(404);
});
});
162 changes: 162 additions & 0 deletions stellar-payment-platform/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,168 @@ describe('POST /register — memo validation', () => {
});
});

describe('POST /register — memo XSS sanitization', () => {
let request;
let app;
let prisma;

const VALID_ADDRESS = 'GBCDEFGHIJKLMNOPQRSTUVWXYZ';

beforeEach(() => {
jest.resetModules();
({ app } = require('./server'));
({ prisma } = require('./prismaClient'));
request = require('supertest');

prisma.user.findUnique.mockReset();
prisma.user.create.mockReset();
prisma.user.findUnique.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'text',
memo: '',
});
});

afterEach(() => {
jest.restoreAllMocks();
});

test('strips ALL HTML tags <script> tags from text memo before saving', async () => {
// Inner text is preserved so the sanitized memo still has content.
const dangerous = '<b>safe text</b><script>alert(1)</script>';
const expected = 'safe text';
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'text',
memo: expected,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous });

expect(res.status).toBe(201);
expect(res.body.memo).not.toMatch(/<script/i);
expect(res.body.memo).not.toMatch(/<\/script>/i);
expect(res.body.memo).not.toMatch(/[<>]/);

// Verify the value passed to Prisma is sanitized — no angle brackets.
const createCall = prisma.user.create.mock.calls[0][0];
expect(createCall.data.memo).toBe(expected);
expect(createCall.data.memo).not.toMatch(/[<>]/);
});

test('strips arbitrary HTML tags but preserves inner text', async () => {
const dangerous = '<b>hello <i>world</i></b>';
const expected = 'hello world';
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'text',
memo: expected,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous });

expect(res.status).toBe(201);
expect(res.body.memo).toBe(expected);

const createCall = prisma.user.create.mock.calls[0][0];
expect(createCall.data.memo).toBe(expected);
expect(createCall.data.memo).not.toMatch(/[<>]/);
});

test('strips javascript: URLs from anchor tags (event-handler vector)', async () => {
const dangerous = '<a href="javascript:alert(1)">click me</a>';
const expected = 'click me';
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'text',
memo: expected,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous });

expect(res.status).toBe(201);
// Sanitized memo MUST NOT contain the dangerous javascript: scheme.
expect(res.body.memo).not.toMatch(/javascript:/i);
expect(res.body.memo).not.toMatch(/[<>]/);
expect(res.body.memo).toBe(expected);
});

test('leaves plain text memo unchanged', async () => {
const plain = 'payment 12345';
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'text',
memo: plain,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: plain });

expect(res.status).toBe(201);
expect(res.body.memo).toBe(plain);
});

test('rejects memo that sanitized to empty string', async () => {
// <script>alert(1)</script> -> '' (only HTML, no inner text)
const dangerous = '<script>alert(1)</script>';
const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'text', memo: dangerous });

// The memo becomes "" after sanitization, and validateMemo rejects a
// memoType with an empty memo (never reaches the DB).
expect(res.status).toBe(400);
expect(prisma.user.create).not.toHaveBeenCalled();
});

test('leaves numeric id memo unchanged (id is strict digits)', async () => {
const numeric = '12345678';
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'id',
memo: numeric,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'id', memo: numeric });

expect(res.status).toBe(201);
expect(res.body.memo).toBe(numeric);
});

test('leaves hex hash memo unchanged (hash is strict hex)', async () => {
const hex = 'a'.repeat(64);
prisma.user.create.mockResolvedValue({
username: 'xssuser*localhost',
address: VALID_ADDRESS,
memoType: 'hash',
memo: hex,
});

const res = await request(app)
.post('/register')
.send({ username: 'xssuser', address: VALID_ADDRESS, memo_type: 'hash', memo: hex });

expect(res.status).toBe(201);
expect(res.body.memo).toBe(hex);
});
});

describe('GET /federation — memo fields in response', () => {
let request;
let app;
Expand Down
Loading