Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/bootstrap/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const { fieldFilterMiddleware } = require('../middleware/fieldFilter');
const { requestTimeout, TIMEOUTS } = require('../middleware/requestTimeout');
const apiVersionMiddleware = require('../middleware/apiVersion');
const requireApiKey = require('../middleware/apiKey');
const { validatePayloadFields } = require('../middleware/validation');
const asyncHandler = require('../utils/asyncHandler');
const log = require('../utils/log');
const requestCounter = require('../utils/requestCounter');
Expand Down Expand Up @@ -146,6 +147,9 @@ function applyMiddleware(app) {
return requestTimeout(GLOBAL_TIMEOUT_MS)(req, res, next);
});

// ─── Mass-assignment protection: reject unknown fields on registered routes ───
app.use(validatePayloadFields);

// ─── Schema version negotiation ──────────────────────────────────────────────
app.use(apiVersionMiddleware);
}
Expand Down
12 changes: 11 additions & 1 deletion src/routes/admin/geoRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ const GeoRuleService = require('../../services/GeoRuleService');
const AuditLogService = require('../../services/AuditLogService');
const log = require('../../utils/log');
const { geoBlockMiddleware } = require('../../middleware/geoBlock');
const { validateSchema } = require('../../middleware/schemaValidation');

const updateGeoRuleSchema = validateSchema({
body: {
fields: {
active: { type: 'boolean', required: false },
description: { type: 'string', required: false, nullable: true },
}
}
});

const router = express.Router();

Expand Down Expand Up @@ -124,7 +134,7 @@ router.post('/', ...auth, async (req, res, next) => {
* Update a geo rule (toggle active, change description).
* Body: { active?: boolean, description?: string }
*/
router.patch('/:id', ...auth, async (req, res, next) => {
router.patch('/:id', ...auth, updateGeoRuleSchema, async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
Expand Down
10 changes: 10 additions & 0 deletions src/routes/admin/pledges.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ const PledgeFulfillmentService = require('../../services/PledgeFulfillmentServic
const WebhookService = require('../../services/WebhookService');
const AuditLogService = require('../../services/AuditLogService');
const log = require('../../utils/log');
const { validateSchema } = require('../../middleware/schemaValidation');

const cancelPledgeSchema = validateSchema({
body: {
fields: {
reason: { type: 'string', required: false, nullable: true },
}
}
});

const VALID_STATUSES = ['pending', 'fulfilled', 'cancelled', 'expired'];

Expand Down Expand Up @@ -150,6 +159,7 @@ router.patch(
router.patch(
'/:id/cancel',
checkPermission(PERMISSIONS.ADMIN_ALL),
cancelPledgeSchema,
asyncHandler(async (req, res, next) => {
try {
const { id } = req.params;
Expand Down
15 changes: 10 additions & 5 deletions src/routes/admin/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ const { payloadSizeLimiter, ENDPOINT_LIMITS } = require('../../middleware/payloa
const { requireAdmin} = require('../../middleware/rbac');
const WebhookService = require('../../services/WebhookService');
const Database = require('../../utils/database');
const { validateSchema } = require('../../middleware/schemaValidation');

const updateWebhookStatusSchema = validateSchema({
body: {
fields: {
status: { type: 'string', required: true, enum: ['active', 'disabled'] },
}
}
});

/**
* GET /admin/webhooks
Expand Down Expand Up @@ -160,15 +169,11 @@ router.post('/:id/retry', requireApiKey, requireAdmin(), payloadSizeLimiter(ENDP
* Update webhook status (disable/enable).
* Body: { status: "disabled" | "active" }
*/
router.patch('/:id', requireApiKey, requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.webhook), asyncHandler(async (req, res, next) => {
router.patch('/:id', requireApiKey, requireAdmin(), updateWebhookStatusSchema, payloadSizeLimiter(ENDPOINT_LIMITS.webhook), asyncHandler(async (req, res, next) => {
try {
const webhookId = parseInt(req.params.id, 10);
const { status } = req.body;

if (!status || !['active', 'disabled'].includes(status)) {
return res.status(400).json({ success: false, error: 'status must be "active" or "disabled"' });
}

// Verify webhook exists
const webhook = await Database.get('SELECT id FROM webhooks WHERE id = ?', [webhookId]);
if (!webhook) {
Expand Down
11 changes: 10 additions & 1 deletion src/routes/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ const streamScheduleIdSchema = validateSchema({
},
});

const updateScheduleSchema = validateSchema({
body: {
fields: {
amount: { types: ['number', 'numberString'], required: false },
frequency: { type: 'string', required: false, enum: ['daily', 'weekly', 'monthly'] },
}
}
});

/**
* POST /stream/create
* Create a recurring donation schedule
Expand Down Expand Up @@ -700,7 +709,7 @@ router.get('/schedules/:id/history', checkPermission(PERMISSIONS.STREAM_READ), s
* Cancelled/suspended schedules cannot be updated (409).
* Requires stream:write permission.
*/
router.patch('/schedules/:id', checkPermission(PERMISSIONS.STREAM_UPDATE), streamScheduleIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.stream), asyncHandler(async (req, res, next) => {
router.patch('/schedules/:id', checkPermission(PERMISSIONS.STREAM_UPDATE), streamScheduleIdSchema, updateScheduleSchema, payloadSizeLimiter(ENDPOINT_LIMITS.stream), asyncHandler(async (req, res, next) => {
try {
const { amount, frequency } = req.body;

Expand Down
77 changes: 58 additions & 19 deletions src/routes/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,65 @@ const walletCreateSchema = validateSchema({
}
});

// Inflation destination schema for PATCH
const inflationDestinationSchema = {
type: 'object',
required: ['destination', 'signedXDR'],
properties: {
destination: { type: 'string' },
signedXDR: { type: 'string' }
const updateWalletLabelSchema = validateSchema({
body: {
fields: {
label: { type: 'string', required: false, nullable: true, maxLength: 100 },
}
}
});

const updateWalletSchema = validateSchema({
body: {
fields: {
label: { type: 'string', required: false, nullable: true, maxLength: 100 },
ownerName: { type: 'string', required: false, nullable: true, maxLength: 200 },
}
}
};
});

const updateHomeDomainSchema = validateSchema({
body: {
fields: {
domain: { type: 'string', required: true },
sourceSecret: { type: 'string', required: true },
}
}
});

const updateWalletLimitsSchema = validateSchema({
body: {
fields: {
daily_limit: { type: 'number', required: false, nullable: true },
monthly_limit: { type: 'number', required: false, nullable: true },
per_transaction_limit: { type: 'number', required: false, nullable: true },
}
}
});

const updateLeaderboardVisibilitySchema = validateSchema({
body: {
fields: {
visible: { type: 'boolean', required: true },
}
}
});

const inflationDestinationSchema = validateSchema({
body: {
fields: {
destination: { type: 'string', required: true },
signedXDR: { type: 'string', required: true },
}
}
});

// PATCH /wallets/:id/inflation-destination
router.patch(
'/:id/inflation-destination',
requireAuth,
requirePermission('wallets:write'),
validateSchema(inflationDestinationSchema),
inflationDestinationSchema,
asyncHandler(async (req, res, next) => {
try {
const { id } = req.params;
Expand Down Expand Up @@ -630,7 +673,7 @@ router.get('/:id', checkPermission(PERMISSIONS.WALLETS_READ), walletIdSchema, ca
* Body: { "label": "string" } — empty string or null clears the label.
* Requires wallets:write permission (not admin).
*/
router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateWalletLabelSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
const { id } = req.params;
const { label } = req.body;
Expand Down Expand Up @@ -676,12 +719,8 @@ router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletId
* PATCH /wallets/:id
* Update wallet metadata (label, ownerName only — publicKey is immutable)
*/
router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), updateWalletSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
// publicKey is immutable — changing it would break all FK relationships
if (req.body.publicKey !== undefined) {
return res.status(400).json({ success: false, error: 'Public key cannot be changed' });
}

const { label, ownerName } = req.body;

Expand Down Expand Up @@ -716,7 +755,7 @@ router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), payloadSizeLim
* Set the home domain on a wallet's Stellar account.
* Body: { domain: string, sourceSecret: string }
*/
router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateHomeDomainSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
const { domain, sourceSecret } = req.body;

Expand Down Expand Up @@ -772,7 +811,7 @@ router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), wa
* Idiomatic alias for PATCH — sets the home domain on a wallet's Stellar account.
* Body: { domain: string, sourceSecret: string }
*/
router.put('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.put('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateHomeDomainSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
const { domain, sourceSecret } = req.body;

Expand Down Expand Up @@ -1001,7 +1040,7 @@ router.get('/:publicKey/transactions', checkPermission(PERMISSIONS.WALLETS_READ)
* Set per-wallet donation limits (admin only)
* Body: { daily_limit, monthly_limit, per_transaction_limit } — all optional, positive number or null
*/
router.patch('/:id/limits', requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.patch('/:id/limits', requireAdmin(), updateWalletLimitsSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId) || userId < 1) {
Expand Down Expand Up @@ -1066,7 +1105,7 @@ router.patch('/:id/limits', requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.w
* Opt a wallet in or out of public leaderboard ranking.
* Body: { visible: boolean }
*/
router.patch('/:id/leaderboard-visibility', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
router.patch('/:id/leaderboard-visibility', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateLeaderboardVisibilitySchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => {
try {
const { visible } = req.body || {};
if (typeof visible !== 'boolean') {
Expand Down
Loading