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
7 changes: 5 additions & 2 deletions backend/src/controllers/__tests__/contractRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ describe('Contract Registry API Integration', () => {
const response = await request(app).get('/api/contracts');

expect(response.status).toBe(500);
expect(response.body.error).toBe('Internal Server Error');
expect(response.body.message).toBe('Registry load failed');
// Regression guard for #495: the response must not echo
// error.message ("Registry load failed") back to the client.
expect(response.body.error).toBe('Failed to load contract registry');
expect(response.body).not.toHaveProperty('message');
expect(JSON.stringify(response.body)).not.toContain('Registry load failed');
expect(logger.error).toHaveBeenCalled();
});

Expand Down
88 changes: 88 additions & 0 deletions backend/src/controllers/__tests__/internalErrorLeak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Regression tests for issue #495: controllers must not leak internal error
* details (database messages, table names, SQL fragments) in 500 responses.
*
* Uses the real tax routes through a real Express app, with only the database
* and logger mocked. The failing call is a genuine Postgres-style error whose
* message contains schema information; production responses must contain none
* of it.
*/

import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import request from 'supertest';
import express from 'express';
import jwt from 'jsonwebtoken';

jest.setTimeout(30_000);

const mockQuery = jest.fn<any>();

jest.unstable_mockModule('../../config/database.js', () => ({
query: mockQuery,
pool: { query: mockQuery },
default: { query: mockQuery },
}));

const { config } = await import('../../config/env.js');
const taxRoutes = (await import('../../routes/taxRoutes.js')).default;
const { TOKEN_TYPE_ACCESS } = await import('../../services/authService.js');

const app = express();
app.use(express.json());
app.use('/api/taxes', taxRoutes);

/** A DB failure that looks exactly like the leak scenario from #495. */
function pgSchemaError() {
return new Error(
'insert into "tax_rules" ("organization_id", "name") returning "id" - relation "tax_rules" does not exist'
);
}

function adminToken() {
return jwt.sign(
{ id: 1, role: 'ADMIN', organizationId: 7, typ: TOKEN_TYPE_ACCESS },
config.JWT_SECRET,
{ expiresIn: '1h' }
);
}

describe('error detail leakage (#495)', () => {
let consoleSpy: any;

beforeEach(() => {
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleSpy.mockRestore();
mockQuery.mockReset();
});

it('500 responses never echo database/schema details to the client', async () => {
mockQuery.mockRejectedValueOnce(pgSchemaError());

const res = await request(app)
.post('/api/taxes/rules')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ organization_id: 7, name: 'VAT', type: 'percentage', value: 5 });

expect(res.status).toBe(500);
const body = JSON.stringify(res.body);
// None of the Postgres error text may reach the client.
expect(body).not.toContain('tax_rules');
expect(body).not.toContain('relation');
expect(body).not.toContain('insert into');
});

it('logs the full error server-side instead of exposing it', async () => {
mockQuery.mockRejectedValueOnce(pgSchemaError());

await request(app)
.post('/api/taxes/rules')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ organization_id: 7, name: 'VAT', type: 'percentage', value: 5 });

const logged = consoleSpy.mock.calls.flat().join('\n');
expect(logged).toContain('tax_rules');
});
});
16 changes: 7 additions & 9 deletions backend/src/controllers/assetController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { AssetService } from '../services/assetService.js';
import { Keypair } from '@stellar/stellar-sdk';
import { pool } from '../config/database.js';
import { sendInternalError } from '../utils/internalError.js';

export class AssetController {
/**
Expand All @@ -28,9 +29,8 @@ export class AssetController {
issuer: asset.issuer,
},
});
} catch (error: any) {
console.error('Issue ORGUSD Error:', error);
res.status(500).json({ error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to issue ORGUSD');
}
}

Expand All @@ -55,9 +55,8 @@ export class AssetController {
txHash,
message: `Successfully clawed back ${amount} ORGUSD from ${fromAccount}`,
});
} catch (error: any) {
console.error('Clawback Error:', error);
res.status(500).json({ error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to execute clawback');
}
}

Expand Down Expand Up @@ -109,9 +108,8 @@ export class AssetController {
limit,
totalPages: Math.ceil(total / limit),
});
} catch (error: any) {
console.error('Get Clawback Logs Error:', error);
res.status(500).json({ error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to retrieve clawback logs');
}
}
}
9 changes: 3 additions & 6 deletions backend/src/controllers/bulkImportController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { csvPayrollImportService } from '../services/csvPayrollImportService.js';
import logger from '../utils/logger.js';
import { sendInternalError } from '../utils/internalError.js';

export class BulkImportController {
async import(req: Request, res: Response) {
Expand Down Expand Up @@ -36,12 +37,8 @@ export class BulkImportController {
},
errors: result.errors,
});
} catch (error: any) {
logger.error('Bulk Import Controller Error:', error);
res.status(500).json({
error: 'Internal Server Error',
message: error.message,
});
} catch (error) {
sendInternalError(res, req, error);
}
}
}
Expand Down
21 changes: 5 additions & 16 deletions backend/src/controllers/cashFlowForecastController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { z } from 'zod';
import { CashFlowForecastService } from '../services/cashFlowForecastService.js';
import logger from '../utils/logger.js';
import { sendInternalError } from '../utils/internalError.js';
import { default as pool } from '../config/database.js';

const forecastQuerySchema = z.object({
Expand Down Expand Up @@ -61,10 +62,7 @@ export class CashFlowForecastController {
});
} catch (error) {
logger.error('Failed to generate cash flow forecast', error);
res.status(500).json({
error: 'Failed to generate cash flow forecast',
message: error instanceof Error ? error.message : 'Unknown error',
});
sendInternalError(res, req, error, 'Failed to generate cash flow forecast');
}
}

Expand Down Expand Up @@ -110,10 +108,7 @@ export class CashFlowForecastController {
});
} catch (error) {
logger.error('Failed to get historical payroll data', error);
res.status(500).json({
error: 'Failed to get historical payroll data',
message: error instanceof Error ? error.message : 'Unknown error',
});
sendInternalError(res, req, error, 'Failed to get historical payroll data');
}
}

Expand Down Expand Up @@ -154,10 +149,7 @@ export class CashFlowForecastController {
});
} catch (error) {
logger.error('Failed to get payroll projections', error);
res.status(500).json({
error: 'Failed to get payroll projections',
message: error instanceof Error ? error.message : 'Unknown error',
});
sendInternalError(res, req, error, 'Failed to get payroll projections');
}
}

Expand Down Expand Up @@ -217,10 +209,7 @@ export class CashFlowForecastController {
});
} catch (error) {
logger.error('Failed to get budget alerts', error);
res.status(500).json({
error: 'Failed to get budget alerts',
message: error instanceof Error ? error.message : 'Unknown error',
});
sendInternalError(res, req, error, 'Failed to get budget alerts');
}
}
}
9 changes: 2 additions & 7 deletions backend/src/controllers/contractController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Request, Response } from 'express';
import { ContractConfigService } from '../services/contractConfigService.js';
import { validateContractEntry, ContractEntry } from '../utils/contractValidator.js';
import logger from '../utils/logger.js';
import { sendInternalError } from '../utils/internalError.js';

export class ContractController {
private static configService = new ContractConfigService();
Expand Down Expand Up @@ -59,13 +60,7 @@ export class ContractController {
} catch (error) {
logger.error('Error in getContracts', error);

const errorResponse = {
error: 'Internal Server Error',
message: error instanceof Error ? error.message : 'Failed to retrieve contract registry',
timestamp: new Date().toISOString()
};

res.status(500).json(errorResponse);
sendInternalError(res, req, error, 'Failed to retrieve contracts');
}
}
}
10 changes: 2 additions & 8 deletions backend/src/controllers/contractRegistryController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { ContractRegistryService } from '../services/contractRegistryService.js';
import logger from '../utils/logger.js';
import { sendInternalError } from '../utils/internalError.js';

export class ContractRegistryController {
/**
Expand Down Expand Up @@ -53,14 +54,7 @@ export class ContractRegistryController {
} catch (error) {
logger.error('Error retrieving contract registry', error);

res.status(500).json({
error: 'Internal Server Error',
message:
error instanceof Error
? error.message
: 'Failed to load contract registry',
timestamp: new Date().toISOString(),
});
sendInternalError(res, req, error, 'Failed to load contract registry');
}
}
}
26 changes: 11 additions & 15 deletions backend/src/controllers/multiSigController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { Keypair } from '@stellar/stellar-sdk';
import { MultiSigService } from '../services/multiSigService.js';
import logger from '../utils/logger.js';
import { sendInternalError } from '../utils/internalError.js';

export class MultiSigController {
/**
Expand All @@ -28,9 +29,8 @@ export class MultiSigController {
);

res.status(200).json({ success: true, data: result });
} catch (error: any) {
logger.error('Multi-sig configuration failed', { error: error.message });
res.status(500).json({ success: false, error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Multi-sig configuration failed');
}
}

Expand All @@ -43,9 +43,8 @@ export class MultiSigController {
const { publicKey } = req.params;
const status = await MultiSigService.getMultiSigStatus(publicKey as string);
res.status(200).json({ success: true, data: status });
} catch (error: any) {
logger.error('Failed to get multi-sig status', { error: error.message });
res.status(500).json({ success: false, error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to get multi-sig status');
}
}

Expand All @@ -69,9 +68,8 @@ export class MultiSigController {
const result = await MultiSigService.addIssuerSigner(issuerKeypair, signerPublicKey, weight);

res.status(200).json({ success: true, data: result });
} catch (error: any) {
logger.error('Failed to add signer', { error: error.message });
res.status(500).json({ success: false, error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to add signer');
}
}

Expand All @@ -96,9 +94,8 @@ export class MultiSigController {
const result = await MultiSigService.removeIssuerSigner(issuerKeypair, publicKey as string);

res.status(200).json({ success: true, data: result });
} catch (error: any) {
logger.error('Failed to remove signer', { error: error.message });
res.status(500).json({ success: false, error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to remove signer');
}
}

Expand All @@ -122,9 +119,8 @@ export class MultiSigController {
const result = await MultiSigService.updateThresholds(issuerKeypair, thresholds);

res.status(200).json({ success: true, data: result });
} catch (error: any) {
logger.error('Failed to update thresholds', { error: error.message });
res.status(500).json({ success: false, error: error.message });
} catch (error) {
sendInternalError(res, req, error, 'Failed to update thresholds');
}
}
}
Loading