Real bugs Loopback catches before they ship

Examples from review runs on open-source and customer codebases. These are the findings that stop incidents before they become post-mortems.

Node.js
db/search.js
Critical
db/search.js +8 −2
1 const { Pool } = require('pg');
2 const pool = new Pool();
3  
4+router.get('/products', async (req, res) => {
5+ const q = `SELECT * FROM products WHERE name LIKE '%${req.body.search}%'`;
6+ const result = await pool.query(q);
7+ res.json(result.rows);
8+});
Loopback finding — db/search.js:5

SQL injection via string interpolation. User input from req.body.search flows directly into the WHERE clause with no parameterization. An attacker can terminate the LIKE expression and append arbitrary SQL — dropping tables, exfiltrating data, or bypassing auth. Severity: Critical.

Suggested fix
// Parameterize all user input — never interpolate into query strings
router.get('/products', async (req, res) => {
  const search = req.body.search ?? '';
+  const result = await pool.query(
+    'SELECT * FROM products WHERE name ILIKE $1',
+    [`%${search}%`]   // value is bound — never parsed as SQL
+  );
  res.json(result.rows);
});
TypeScript
routes/invoices.ts
High
routes/invoices.ts +12 −1
14 router.get('/api/invoices/:id', requireAuth, async (req, res) => {
15+ const invoice = await db.invoices.findById(req.params.id);
16+ if (!invoice) return res.status(404).json({ error: 'Not found' });
17+ return res.json(invoice); // ← no ownership check
18+});
Loopback finding — routes/invoices.ts:15–17

IDOR — Insecure Direct Object Reference. /api/invoices/:id returns invoice data without checking that req.user.id matches invoice.owner_id. Any authenticated user can read any other customer's invoice by iterating IDs. Severity: High.

Suggested fix
router.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  if (!invoice) return res.status(404).json({ error: 'Not found' });
+  if (invoice.owner_id !== req.user.id) {
+    return res.status(403).json({ error: 'Forbidden' });
+  }
  return res.json(invoice);
});
Node.js
services/wallet.js
High
services/wallet.js +10 −0
22+async function creditBalance(userId, amount) {
23+ const row = await pool.query(
24+ 'SELECT balance FROM wallets WHERE user_id = $1', [userId]);
25+ const newBalance = row.rows[0].balance + amount; // ← read-then-write
26+ await pool.query(
27+ 'UPDATE wallets SET balance = $1 WHERE user_id = $2',
28+ [newBalance, userId]);
29+}
Loopback finding — services/wallet.js:23–28

Race condition in balance update. The read-then-write pattern at lines 23–28 is not atomic. If two concurrent requests both read balance = 100 and then write 100 + 50, the final balance is 150 instead of 200 — one credit is silently lost. Under load, this divergence compounds. Use a single atomic SQL update instead. Severity: High.

Suggested fix
async function creditBalance(userId, amount) {
+  // Atomic increment — Postgres handles the read internally under a row lock
+  await pool.query(
+    'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2',
+    [amount, userId]
+  );
}
JavaScript
config/database.js
Critical
config/database.js +9 −0
1+module.exports = {
2+ host: 'db.us-east-1.rds.amazonaws.com',
3+ database: 'production_db',
4+ user: 'admin',
5+ password: 'Xk9$mP2#vLq8rNz!', // ← hardcoded credential
6+ stripe_secret: 'sk_live_51H...',
7+};
Loopback finding — config/database.js:4–6

Hardcoded credentials committed to source control. A database password and live Stripe secret key are in plaintext in a committed file. Anyone with repo access — past or present — can extract these. Git history makes this permanent even after deletion. Rotate both credentials immediately and move all secrets to environment variables. Severity: Critical.

Suggested fix
// config/database.js — read from environment, never hardcode
module.exports = {
  host:          process.env.DB_HOST,
  database:      process.env.DB_NAME,
  user:          process.env.DB_USER,
+  password:      process.env.DB_PASSWORD,    // set in .env (local) or infra secrets (prod)
+  stripe_secret: process.env.STRIPE_SECRET_KEY,
};

// Add config/database.js values to .env (gitignored) and
// provision real secrets via your deploy platform's secret store.
TypeScript
services/email.ts
Medium
services/email.ts +14 −0
31+export async function sendWelcomeEmail(userId: string) {
32+ try {
33+ const user = await db.users.findById(userId);
34+ await mailer.send({ to: user.email, template: 'welcome' });
35+ } catch (err) {
36+ console.error('email failed', err); // ← error swallowed
37+ return { success: true }; // ← caller sees success
38+ }
39+}
Loopback finding — services/email.ts:35–37

Swallowed error returns false success to caller. The catch block logs the error but then returns { success: true } — the caller has no way to know the email failed. Every downstream system (retry logic, analytics, user notifications) believes the email was delivered. This pattern makes silent failures the default and makes debugging nearly impossible under load. Severity: Medium.

Suggested fix
export async function sendWelcomeEmail(userId: string): Promise<void> {
  try {
    const user = await db.users.findById(userId);
    await mailer.send({ to: user.email, template: 'welcome' });
  } catch (err) {
    // Log for observability, then re-throw so the caller can retry / alert
+    logger.error({ userId, err }, 'welcome email failed');
+    throw err;   // propagate — don't mask failures
  }
}

Want Loopback catching bugs like these on your PRs?

Loopback connects to GitHub in under 5 minutes and starts reviewing every PR automatically — security, logic, and style findings, inline.