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
20 changes: 20 additions & 0 deletions infra/apisix/routes/rate-limit-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,25 @@ endpoint_overrides:
policy: internal
- uri: /socket.io/*
policy: websocket
- uri: /api/trpc/cashIn.*
policy: transaction
- uri: /api/trpc/cashOut.*
policy: transaction
- uri: /api/trpc/floatTopUp.*
policy: transaction
- uri: /api/trpc/stablecoinRails.*
policy: transaction
- uri: /api/trpc/crossBorderRemittance.*
policy: transaction
- uri: /api/trpc/agentLoanFacility.*
policy: transaction
- uri: /api/trpc/nfcTapToPay.*
policy: transaction
- uri: /api/trpc/ecommerceOrders.*
policy: transaction
- uri: /api/trpc/billPayments.*
policy: transaction
- uri: /api/trpc/splitPayments.*
policy: transaction
- uri: /health
policy: null # no rate limiting on health checks
58 changes: 58 additions & 0 deletions infra/security/waf/openappsec-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,64 @@ spec:
- /api/stripe/webhook # Stripe sends from various locations
- /api/trpc/healthCheck.status # Monitoring from anywhere

---
apiVersion: openappsec.io/v1beta1
kind: Practice
metadata:
name: financial-transaction-protection
namespace: 54link-billing
spec:
rateLimiting:
rules:
- name: cash-in-limit
uri: /api/trpc/cashIn.*
limit: 30
interval: 60
scope: source-ip
- name: cash-out-limit
uri: /api/trpc/cashOut.*
limit: 30
interval: 60
scope: source-ip
- name: stablecoin-limit
uri: /api/trpc/stablecoinRails.*
limit: 10
interval: 60
scope: source-ip
- name: cross-border-limit
uri: /api/trpc/crossBorderRemittance.*
limit: 10
interval: 60
scope: source-ip
- name: loan-limit
uri: /api/trpc/agentLoanFacility.*
limit: 5
interval: 60
scope: source-ip
- name: float-topup-limit
uri: /api/trpc/floatTopUp.*
limit: 10
interval: 60
scope: source-ip
- name: nfc-payment-limit
uri: /api/trpc/nfcTapToPay.*
limit: 60
interval: 60
scope: source-ip
- name: ecommerce-order-limit
uri: /api/trpc/ecommerceOrders.*
limit: 30
interval: 60
scope: source-ip
webAttacks:
overrideMode: prevent
protections:
- name: financial-sql-injection
mode: prevent
sensitivity: critical
- name: financial-parameter-tampering
mode: prevent

Comment on lines +279 to +336

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 WAF rate-limit policy omits two transaction endpoints that APISIX covers

The APISIX rate-limit policy at infra/apisix/routes/rate-limit-policy.yaml:91-94 includes billPayments and splitPayments with the transaction policy, but the new WAF financial-transaction-protection Practice at infra/security/waf/openappsec-policy.yaml:279-336 does not include rate-limiting rules for these two endpoints. The WAF covers cashIn, cashOut, stablecoinRails, crossBorderRemittance, agentLoanFacility, floatTopUp, nfcTapToPay, and ecommerceOrders — but billPayments and splitPayments are missing. This may be intentional (relying on APISIX alone for those) or an oversight in the WAF layer.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

---
apiVersion: openappsec.io/v1beta1
kind: Practice
Expand Down
111 changes: 100 additions & 11 deletions mobile-flutter/mobile-flutter/lib/screens/bill_payment_screen.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,111 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../services/api_service.dart';


class BillPaymentScreen extends StatelessWidget {
class BillPaymentScreen extends StatefulWidget {
const BillPaymentScreen({super.key});
@override
State<BillPaymentScreen> createState() => _BillPaymentScreenState();
}

class _BillPaymentScreenState extends State<BillPaymentScreen> {
final _formKey = GlobalKey<FormState>();
String _billerId = '';
String _customerRef = '';
double _amount = 0;
bool _loading = false;
List<Map<String, dynamic>> _billers = [];

@override
void initState() {
super.initState();
_loadBillers();
}

Future<void> _loadBillers() async {
setState(() => _loading = true);
try {
final data = await ApiService.get('/billers/list?page=1&limit=50');
setState(() => _billers = List<Map<String, dynamic>>.from(data['items'] ?? []));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to load billers: $e')));
} finally {
setState(() => _loading = false);
}
}

Future<void> _validateBill() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _loading = true);
try {
final result = await ApiService.post('/bill-payments/validate', {
'billerId': _billerId, 'customerRef': _customerRef, 'amount': _amount,
});
if (mounted) {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('Bill Validated'),
content: Text('Customer: ${result['customerName']}\nAmount: NGN ${_amount.toStringAsFixed(2)}'),
actions: [
TextButton(onPressed: () { Navigator.pop(context); _payBill(); }, child: const Text('Pay Now')),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
],
));
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Validation failed: $e')));
} finally {
setState(() => _loading = false);
}
}

Future<void> _payBill() async {
setState(() => _loading = true);
try {
await ApiService.post('/bill-payments/pay', {
'billerId': _billerId, 'customerRef': _customerRef, 'amount': _amount,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment successful')));
Navigator.pop(context);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Payment failed: $e')));
} finally {
setState(() => _loading = false);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('BillPaymentScreen'.replaceAll('Screen', '').replaceAllMapped(RegExp(r'[A-Z]'), (m) => ' ${m[0]}').trim())),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('BillPaymentScreen', style: Theme.of(context).textTheme.headlineSmall),
appBar: AppBar(title: const Text('Bill Payment')),
body: _loading ? const Center(child: CircularProgressIndicator()) : Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(children: [
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Biller'),
items: _billers.map((b) => DropdownMenuItem(value: b['id']?.toString() ?? '', child: Text(b['name'] ?? 'Unknown'))).toList(),
onChanged: (v) => _billerId = v ?? '',
validator: (v) => v == null || v.isEmpty ? 'Select a biller' : null,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Customer Reference', hintText: 'Meter number / Account ID'),
onSaved: (v) => _customerRef = v ?? '',
validator: (v) => v == null || v.isEmpty ? 'Enter reference' : null,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Amount (NGN)', prefixText: '₦ '),
keyboardType: TextInputType.number,
onSaved: (v) => _amount = double.tryParse(v ?? '0') ?? 0,
validator: (v) { final n = double.tryParse(v ?? ''); return n == null || n <= 0 ? 'Enter valid amount' : null; },
),
const SizedBox(height: 24),
ElevatedButton(onPressed: () => context.pop(), child: const Text('Back')),
],
ElevatedButton(onPressed: _validateBill, child: const Text('Validate & Pay')),
]),
),
),
);
Expand Down
65 changes: 54 additions & 11 deletions mobile-flutter/mobile-flutter/lib/screens/cash_in_screen.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,65 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../services/api_service.dart';


class CashInScreen extends StatelessWidget {
class CashInScreen extends StatefulWidget {
const CashInScreen({super.key});
@override
State<CashInScreen> createState() => _CashInScreenState();
}

class _CashInScreenState extends State<CashInScreen> {
final _formKey = GlobalKey<FormState>();
double _amount = 0;
String _customerPhone = '';
bool _loading = false;

Future<void> _processCashIn() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _loading = true);
try {
final result = await ApiService.post('/cash-in/create', {
'amount': _amount, 'customerPhone': _customerPhone, 'channel': 'mobile',
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cash-in successful: ${result['txRef']}')));
Navigator.pop(context);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cash-in failed: $e')));
} finally {
setState(() => _loading = false);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('CashInScreen'.replaceAll('Screen', '').replaceAllMapped(RegExp(r'[A-Z]'), (m) => ' ${m[0]}').trim())),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('CashInScreen', style: Theme.of(context).textTheme.headlineSmall),
appBar: AppBar(title: const Text('Cash In')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Customer Phone', prefixText: '+234 '),
keyboardType: TextInputType.phone,
onSaved: (v) => _customerPhone = v ?? '',
validator: (v) => v == null || v.length < 10 ? 'Enter valid phone' : null,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Amount (NGN)', prefixText: '₦ '),
keyboardType: TextInputType.number,
onSaved: (v) => _amount = double.tryParse(v ?? '0') ?? 0,
validator: (v) { final n = double.tryParse(v ?? ''); return n == null || n <= 0 ? 'Enter valid amount' : null; },
),
const SizedBox(height: 24),
ElevatedButton(onPressed: () => context.pop(), child: const Text('Back')),
],
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: _loading ? null : _processCashIn,
child: _loading ? const CircularProgressIndicator() : const Text('Process Cash In'),
)),
]),
),
),
);
Expand Down
73 changes: 62 additions & 11 deletions mobile-flutter/mobile-flutter/lib/screens/cash_out_screen.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,73 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../services/api_service.dart';


class CashOutScreen extends StatelessWidget {
class CashOutScreen extends StatefulWidget {
const CashOutScreen({super.key});
@override
State<CashOutScreen> createState() => _CashOutScreenState();
}

class _CashOutScreenState extends State<CashOutScreen> {
final _formKey = GlobalKey<FormState>();
double _amount = 0;
String _customerPhone = '';
String _pin = '';
bool _loading = false;

Future<void> _processCashOut() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _loading = true);
try {
final result = await ApiService.post('/cash-out/create', {
'amount': _amount, 'customerPhone': _customerPhone, 'pin': _pin, 'channel': 'mobile',
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cash-out successful: ${result['txRef']}')));
Navigator.pop(context);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cash-out failed: $e')));
} finally {
setState(() => _loading = false);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('CashOutScreen'.replaceAll('Screen', '').replaceAllMapped(RegExp(r'[A-Z]'), (m) => ' ${m[0]}').trim())),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('CashOutScreen', style: Theme.of(context).textTheme.headlineSmall),
appBar: AppBar(title: const Text('Cash Out')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Customer Phone', prefixText: '+234 '),
keyboardType: TextInputType.phone,
onSaved: (v) => _customerPhone = v ?? '',
validator: (v) => v == null || v.length < 10 ? 'Enter valid phone' : null,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Amount (NGN)', prefixText: '₦ '),
keyboardType: TextInputType.number,
onSaved: (v) => _amount = double.tryParse(v ?? '0') ?? 0,
validator: (v) { final n = double.tryParse(v ?? ''); return n == null || n <= 0 ? 'Enter valid amount' : null; },
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'PIN'),
obscureText: true, maxLength: 4,
onSaved: (v) => _pin = v ?? '',
validator: (v) => v == null || v.length != 4 ? 'Enter 4-digit PIN' : null,
),
const SizedBox(height: 24),
ElevatedButton(onPressed: () => context.pop(), child: const Text('Back')),
],
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: _loading ? null : _processCashOut,
child: _loading ? const CircularProgressIndicator() : const Text('Process Cash Out'),
)),
]),
),
),
);
Expand Down
Loading