API Security & Testing
Top threats and our defence
Treat the Android app as untrusted: anyone can copy its requests and change them. Everything that matters is decided on the server.
| Threat | Example | KartEasy defence | Where |
|---|---|---|---|
| SQL injection | q=' OR 1=1 -- | PDO prepared statements, whitelisted ORDER BY | every endpoint |
| IDOR | open order id=1 of another user | AND user_id = ? in every query | cart, address, orders |
| Price tampering | send "price": 1 | prices and totals only from the database | cart.php, place.php |
| Overselling | two buyers, one item | transaction + SELECT … FOR UPDATE | orders/place.php |
| Stolen password DB | backup leaked | password_hash, tokens stored as SHA-256 | auth, user_tokens |
| Fake reviews | review without buying | only delivered orders | reviews/add.php |
| XSS in admin | name = <script> | h() on every output | admin/* |
| CSRF in admin | hidden form on another site | CSRF token + hash_equals | admin/* |
| Malicious upload | shell.php.jpg | getimagesize, random name, no PHP in uploads | admin/inc/bootstrap.php |
| Info leak | SQL error shown to users | DEBUG off, errors only in the log | helpers.php |
# Attacker tries to buy at ₹1 by sending a price
curl -X POST $B/cart/add.php -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"product_id":7,"qty":1,"price":1}'# SQL injection attempt in the search box curl "$B/products.php?q=' OR '1'='1&sort=price_asc;DROP TABLE users"
Security checklist
- 1HTTPS on the domain (free SSL), BASE_URL starts with https://
- 2
DEBUGis false in config.php - 3Admin password changed; admin folder optionally renamed
- 4
includes/,tests/anddatabase.sqlreturn 403 in the browser - 5Uploading a .php file in the admin panel is refused
- 6Every customer query that uses an id also checks
user_id - 7Smoke test: 26 passed, 0 failed
Errors and logs
When something breaks, users see a friendly message while you see the real reason in the error log (Hostinger: hPanel → Advanced → Error logs, or error_log file in the folder).
<?php
// helpers.php: every uncaught error
set_exception_handler(function ($e) {
error_log('[KartEasy] ' . $e->getMessage()); // goes to the server error log
$msg = DEBUG ? $e->getMessage() : 'Something went wrong. Please try again.';
json_out(array('success' => false, 'message' => $msg), 500);
});DEBUG = true (your PC)
- real message in JSON
- e.g. "Unknown column 'prise'"
- fast to fix bugs
DEBUG = false (live)
- "Something went wrong."
- details only in error_log
- nothing useful for attackers
Testing with curl
curl comes with Windows 10+, macOS and Linux. It is the quickest way to try an endpoint while you write it.
B=http://localhost:8000/api
# 1. Public endpoints
curl $B/home.php
curl "$B/products.php?q=watch&sort=price_asc"
# 2. Login and keep the token in a variable (needs jq: apt/brew install jq)
TOKEN=$(curl -s -X POST $B/auth/login.php -H 'Content-Type: application/json' \
-d '{"email":"test@karteasy.in","password":"Test@123"}' | jq -r .data.token)
# 3. Protected endpoints
curl -X POST $B/cart/add.php -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"product_id":7,"qty":1}'
curl $B/cart/list.php -H "Authorization: Bearer $TOKEN"
# 4. See the HTTP status too
curl -s -o /dev/null -w "%{http_code}\n" $B/product.php?id=999 # 404Testing with Postman
- 1Create a collection KartEasy and a variable
base=http://localhost:8000/api - 2Add POST {{base}}/auth/login.php, Body → raw → JSON with email and password
- 3In the request Tests tab:
pm.collectionVariables.set("token", pm.response.json().data.token) - 4On the collection → Authorization → Bearer Token →
{{token}}(every request inherits it) - 5Add the other endpoints, then Run collection to test them all in order
Automatic smoke test (26 checks)
Clicking through endpoints by hand after every change is slow. tests/smoke-test.php runs a whole customer journey and checks each answer. It uses only file_get_contents, so it runs anywhere PHP runs — no extra library.
<?php
/*
* KartEasy API smoke test – runs a full shopping flow and checks every answer.
*
* php tests/smoke-test.php http://localhost:8000
* php tests/smoke-test.php https://yourdomain.com/karteasy
*
* It creates a new test customer each time (test+<time>@example.com), so it is
* safe to run again and again. Run it from the command line only.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$base = rtrim(isset($argv[1]) ? $argv[1] : 'http://localhost:8000', '/') . '/api/';
$token = null;
$passed = 0;
$failed = 0;
/** Sends one request and returns array(httpStatus, decodedJson). */
function call($method, $path, $body = null)
{
global $base, $token;
$headers = array('Accept: application/json');
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if ($token !== null) {
$headers[] = 'Authorization: Bearer ' . $token;
}
$context = stream_context_create(array('http' => array(
'method' => $method,
'header' => implode("\r\n", $headers),
'content' => $body === null ? '' : json_encode($body),
'ignore_errors' => true, // also read the body of 4xx / 5xx answers
'timeout' => 15,
)));
$raw = @file_get_contents($base . $path, false, $context);
$status = 0;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$status = (int) $m[1];
}
return array($status, json_decode((string) $raw, true));
}
function check($label, $ok, $extra = '')
{
global $passed, $failed;
if ($ok) {
$passed++;
echo " PASS $label\n";
} else {
$failed++;
echo " FAIL $label $extra\n";
}
}
echo "KartEasy API test → $base\n\n";
list($s, $r) = call('GET', '');
check('API is running and DB connected', $s === 200 && $r['data']['database'] === 'connected');
list($s, $r) = call('GET', 'home.php');
check('home.php has banners, categories and products', $s === 200 && count($r['data']['banners']) > 0 && count($r['data']['new_arrivals']) > 0);
$productId = $r['data']['new_arrivals'][0]['id'];
list($s, $r) = call('GET', 'products.php?q=a&sort=price_asc&limit=5');
$prices = array();
foreach ($r['data']['items'] as $p) {
$prices[] = $p['price'];
}
$sorted = $prices;
sort($sorted);
check('products.php search sorted by price', $s === 200 && $prices === $sorted);
list($s, $r) = call('GET', 'product.php?id=999999');
check('unknown product gives 404', $s === 404 && $r['success'] === false);
list($s, $r) = call('GET', 'cart/list.php');
check('cart without token gives 401', $s === 401);
$email = 'test+' . time() . '@example.com';
list($s, $r) = call('POST', 'auth/register.php', array('name' => 'Smoke Test', 'email' => $email, 'phone' => '9876501234', 'password' => 'secret12'));
check('register a new customer', $s === 200 && strlen($r['data']['token']) === 64, json_encode($r));
$token = $r['data']['token'];
list($s, $r) = call('POST', 'auth/register.php', array('name' => 'Smoke Test', 'email' => $email, 'password' => 'secret12'));
check('same email again gives 409', $s === 409);
list($s, $r) = call('GET', 'auth/me.php');
check('me.php returns the new user', $s === 200 && $r['data']['email'] === $email);
list($s, $r) = call('POST', 'cart/add.php', array('product_id' => $productId, 'qty' => 1));
check('add to cart', $s === 200 && $r['data']['item_count'] === 1);
list($s, $r) = call('POST', 'cart/add.php', array('product_id' => $productId, 'qty' => 50));
check('too many gives 409', $s === 409);
list($s, $r) = call('POST', 'cart/update.php', array('product_id' => $productId, 'qty' => 2));
$cart = $r['data'];
check('update quantity to 2', $s === 200 && $cart['item_count'] === 2);
check('total = subtotal - discount + delivery', abs($cart['total'] - ($cart['subtotal'] - $cart['discount'] + $cart['delivery_fee'])) < 0.01);
list($s, $r) = call('POST', 'wishlist/toggle.php', array('product_id' => $productId));
check('wishlist toggle on', $s === 200 && $r['data']['in_wishlist'] === true);
list($s, $r) = call('POST', 'wishlist/toggle.php', array('product_id' => $productId));
check('wishlist toggle off', $s === 200 && $r['data']['in_wishlist'] === false);
list($s, $r) = call('POST', 'address/save.php', array('name' => 'Smoke Test', 'phone' => '12345', 'line1' => 'x', 'city' => 'y', 'state' => 'z', 'pincode' => '452001'));
check('bad phone gives 422', $s === 422);
list($s, $r) = call('POST', 'address/save.php', array('name' => 'Smoke Test', 'phone' => '9876501234', 'line1' => '1 Test Street', 'city' => 'Indore', 'state' => 'Madhya Pradesh', 'pincode' => '452001'));
check('save address (first one is default)', $s === 200 && $r['data']['is_default'] === true);
$addressId = $r['data']['id'];
list($s, $r) = call('POST', 'coupons/apply.php', array('code' => 'NO-SUCH-CODE'));
check('invalid coupon gives 422', $s === 422);
list($s, $r) = call('POST', 'orders/place.php', array('address_id' => $addressId, 'coupon' => ''));
check('place order', $s === 200 && strpos($r['data']['order_no'], 'KE') === 0, json_encode($r));
$orderId = $r['data']['order_id'];
list($s, $r) = call('GET', 'cart/list.php');
check('cart is empty after the order', $s === 200 && count($r['data']['items']) === 0);
list($s, $r) = call('GET', 'orders/detail.php?id=' . $orderId);
check('order detail has items and timeline', $s === 200 && count($r['data']['items']) === 1 && count($r['data']['timeline']) === 4);
list($s, $r) = call('POST', 'reviews/add.php', array('product_id' => $productId, 'rating' => 5, 'comment' => 'test'));
check('review before delivery gives 403', $s === 403);
list($s, $r) = call('POST', 'orders/cancel.php', array('id' => $orderId));
check('cancel order (stock goes back)', $s === 200 && $r['data']['status'] === 'cancelled');
list($s, $r) = call('POST', 'orders/cancel.php', array('id' => $orderId));
check('cancel again gives 409', $s === 409);
list($s, $r) = call('GET', 'orders/detail.php?id=1');
check('cannot open another user\'s order (404)', $s === 404 || ($s === 200 && $orderId === 1));
list($s, $r) = call('POST', 'auth/logout.php', array());
check('logout', $s === 200);
list($s, $r) = call('GET', 'auth/me.php');
check('old token no longer works', $s === 401);
echo "\n$passed passed, $failed failed\n";
exit($failed > 0 ? 1 : 0);Changed a query? Run the smoke test. If something turns into FAIL, you know immediately which flow you broke — before your users do.
Extra hardening ideas
- Login rate limit in the API: a
login_attemptstable (email, ip, time) and refuse after 5 failures in 15 minutes. - Email verification / OTP on register before the first order.
- Refresh tokens: short-lived access tokens plus a long refresh token.
- Daily database backup (Hostinger does this on most plans — check it is on).
