Static Code Review

Security Findings Report

2026-06-09 · 8 findings

8

Total Findings

3

Critical

5

High

6

Confirmed

CR-1CRITICALLIKELY

Quotation Request Data Isolation Breach (IDOR Vulnerability)

Not fully CONFIRMED — depends on Base44 RLS enforcement

Affected Files

pages/MyQuotations.jsx · lines 31-34
base44.entities.QuotationRequest.list('-created_date', 50)
  .then(setRequests)
  .catch(() => setRequests([]))
  .finally(() => setLoading(false));
entities/QuotationRequest.json · lines RLS config
"read": {
  "$or": [
    { "created_by_id": "{{user.id}}" },
    { "user_condition": { "role": "admin" } }
  ]
}

Why It Is Vulnerable

  • Frontend calls .list() with no filter argument
  • Relies entirely on Base44 platform to enforce created_by_id RLS rule
  • No defense-in-depth: frontend has zero ownership verification
  • Inconsistency: CustomerDashboard.jsx line 373 uses safer explicit .filter() pattern
  • If platform-level RLS is bypassable, all quotations from all customers become accessible

Exploitation Scenario

Customer A calls .list() and receives Customer B's quotations including product names, quantities, pricing, and notes. Competitive intelligence theft and privacy breach.

Recommended Fix

Replace .list() with explicit .filter({ created_by_id: user.id }). Match the safer pattern already used in CustomerDashboard.jsx line 373.
CR-2CRITICALCONFIRMED

Customer Dashboard Route Protection Missing

Active vulnerability — no framework-level route protection

Affected Files

App.jsx · lines Routes section
<Route path="/dashboard" element={<CustomerDashboard />} />
<Route path="/my-quotations" element={<MyQuotations />} />
<Route path="/profile" element={<CustomerProfile />} />
pages/CustomerDashboard.jsx · lines 368-400
useEffect(() => {
  base44.auth.me()
    .then((u) => {
      setUser(u || null);
    })
    .catch(() => setUser(null))
    .finally(() => setAuthChecked(true));
}, []);

if (!authChecked) return <Loader2 ... />;
if (!user) return <div>Sign in to view...</div>;

Why It Is Vulnerable

  • Customer portal routes completely outside <ProtectedRoute> framework wrapper
  • Authentication checked only via base44.auth.me() inside component (asynchronous)
  • Application-level auth check does not prevent page load or rendering
  • No hard redirect to /login; shows soft 'Sign in' prompt instead
  • Comparison: admin routes correctly use <ProtectedRoute> wrapper

Exploitation Scenario

Unauthenticated user hits /dashboard directly. Page renders with loading spinner, then shows 'Sign in' prompt. No framework-level block. Cached or slow-loading page could briefly expose partial content.

Recommended Fix

Wrap all customer portal routes in <ProtectedRoute> with hard redirect: <Route element={<ProtectedRoute unauthenticatedElement={<Navigate to="/login" replace />} />}>
CR-3CRITICALLIKELY

ERP Credentials Exposure via Entity Storage

Significant architectural risk; not exploited in current codebase but high-impact if breached

Affected Files

entities/ERPSettings.json · lines properties section
"erpUrl": { "type": "string" },
"apiKey": { "type": "string" },
"apiSecret": { "type": "string" }
functions/syncERPProducts.js · lines 206-212
const settingsList = await base44.asServiceRole.entities.ERPSettings.list();
const cfg = settingsList.find(r => r.isActive) || settingsList[0];
const authHeader = `token ${cfg.apiKey.trim()}:${cfg.apiSecret.trim()}`;
functions/testERPConnectionOnly.js · lines 13-21
const settingsList = await base44.asServiceRole.entities.ERPSettings.list();
const cfg = settingsList.find((r) => r.isActive) || settingsList[0] || null;
const authHeader = `token ${cfg.apiKey.trim()}:${cfg.apiSecret.trim()}`;

Why It Is Vulnerable

  • ERP API credentials stored as plain text in database (not environment variables)
  • Any database breach or backup exposure reveals credentials directly
  • All admin users can view apiKey and apiSecret in Admin ERP Settings page UI
  • base44.asServiceRole bypasses RLS — any service-role function could access them
  • No credential rotation mechanism, expiry, or audit log for credential access
  • Against security best practice: secrets should be in environment variables, not DB

Exploitation Scenario

Admin user (legitimate or compromised via privilege escalation) accesses Admin ERP Settings page and copies apiKey and apiSecret. With these credentials, they make direct API calls to ERPNext to read all customers, invoices, delivery notes, and confidential business records outside the application.

Recommended Fix

Use environment variables instead of entity fields: const erpUrl = Deno.env.get('ERP_URL'); const apiKey = Deno.env.get('ERP_API_KEY'). Remove apiKey and apiSecret fields from ERPSettings entity entirely.
HR-1HIGHCONFIRMED

Inconsistent Admin Authorization Logic

Active vulnerability — allows unauthenticated calls in retrySyncCustomer

Affected Files

functions/retrySyncCustomer.js · lines 8-12
const user = await base44.auth.me().catch(() => null);
if (user && user.role !== 'admin') {
  return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// If user is null, the if() is FALSE and execution CONTINUES
functions/syncERPProducts.js · lines 200-203
const user = await base44.auth.me();
if (!user || user.role !== 'admin') {
  return Response.json({ error: 'Forbidden: Admin access required' }, { status: 403 });
}
functions/testERPConnectionOnly.js · lines 7-10
const user = await base44.auth.me();
if (!user || user.role !== 'admin') {
  return Response.json({ error: 'Forbidden' }, { status: 403 });
}

Why It Is Vulnerable

  • retrySyncCustomer.js uses: if (user && user.role !== 'admin') — logical gap
  • When user is null (unauthenticated), condition evaluates to if(null && ...) = false
  • The authorization check is skipped entirely for unauthenticated requests
  • Function proceeds to call base44.asServiceRole and make live ERP API calls
  • Inconsistent: syncERPProducts.js uses correct pattern: if (!user || user.role !== 'admin')
  • Comment acknowledges intent to allow scheduled automations, but also allows any HTTP request

Exploitation Scenario

Attacker discovers the function endpoint URL and makes direct POST request without auth token. Function processes all pending SyncLog entries, makes live ERP API calls to create/update Customers, and modifies user profiles via base44.asServiceRole.

Recommended Fix

Add automation secret header validation: const automationKey = req.headers.get('x-automation-key'); if (!user && automationKey !== AUTOMATION_SECRET) { return 403; } if (user && user.role !== 'admin') { return 403; }
HR-2HIGHCONFIRMED

File Upload Type Validation Bypass via Drag-and-Drop

Active vulnerability — drag-and-drop bypasses all file type restrictions

Affected Files

components/admin/ImageUploader.jsx · lines 1-69
export default function ImageUploader({ ..., accept = '.jpg,.jpeg,.png,.webp' }) {
  const handleFile = async (file) => {
    if (!file) return;
    setUploading(true);
    const { file_url } = await base44.integrations.Core.UploadFile({ file });
    onChange(file_url);
    setUploading(false);
  };
  const handleDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    const file = e.dataTransfer.files[0];
    if (file) handleFile(file);
  };

Why It Is Vulnerable

  • accept attribute is browser-side only — filters file picker dialog UI only
  • accept does NOT prevent any file from being uploaded — trivially bypassed
  • handleDrop does not check file type or size before upload
  • Any file (exe, html, svg, js, pdf) can be dragged onto the drop zone
  • handleFile has zero validation: only checks if(file) exists
  • No file.size check anywhere in the component
  • Drag-and-drop path bypasses accept attribute entirely

Exploitation Scenario

Admin user (or compromised account) drags .svg file containing embedded JavaScript onto upload zone. SVG is uploaded and stored. If served with Content-Type: image/svg+xml, script executes in any browser that renders it — stored XSS via file upload.

Recommended Fix

Add validation in handleFile: const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; const MAX_SIZE = 5*1024*1024; if (!ALLOWED_TYPES.includes(file.type)) { toast.error('Only JPG, PNG, WebP allowed'); return; } if (file.size > MAX_SIZE) { toast.error('File under 5MB'); return; }
HR-3HIGHCONFIRMED

SyncLog Entity Stores Sensitive Registration Data Indefinitely

Active vulnerability — PII stored without retention policy or minimal necessary principle

Affected Files

entities/SyncLog.json · lines properties section
"registration_data": {
  "type": "string",
  "description": "JSON-encoded registration payload for retry"
}
functions/syncCustomerToERP.js · lines 36-37, 155-168
registration_data: JSON.stringify(payload),

await base44.asServiceRole.entities.SyncLog.create({
  user_email: email,
  user_id: user.id,
  customer_name: companyName,
  customer_type: customerType || 'Company',
  business_type: businessType || '',
  phone: phone || '',
  erp_customer_id: erpCustomerId || '',
  erp_contact_id: erpContactId || '',
  sync_status: erpCustomerId ? 'success' : 'failed',
  error_message: syncError || '',
  retry_count: 0,
  registration_data: JSON.stringify(payload)
});

Why It Is Vulnerable

  • Every registration permanently stores PII (email, phone, name, company) in separate log
  • No data retention policy — SyncLog records persist indefinitely
  • RLS is admin-only — any admin can read every customer's registration data
  • registration_data is redundant for success records — data already on User profile
  • Only needed for pending/failed retries, not success cases
  • Violates GDPR data minimization principle

Exploitation Scenario

Malicious admin (or compromised admin account) queries SyncLog and extracts registration_data. They now have dump of every customer's name, email, phone, business type in JSON format — useful for phishing, competitor intelligence, or data resale.

Recommended Fix

Only store registration_data for non-success: registration_data: syncStatus !== 'success' ? JSON.stringify(payload) : null. Clear PII on success: registration_data: null. Implement retention policy: auto-delete SyncLog after 30 days.
HR-4HIGHCONFIRMED

Weak Input Validation — Multiple Distinct Weak Points

Active vulnerability — no format validation on email, phone, company name

Affected Files

pages/Register.jsx · lines 40-52
const validate = () => {
  if (password.length < 8) return 'Password must be at least 8 characters.';
  if (password !== confirmPassword) return 'Passwords do not match.';
  if (customerType === 'Company') {
    if (!companyName.trim()) return 'Company Name is required.';
    if (!contactPerson.trim()) return 'Contact Person is required.';
    if (!businessType) return 'Business Type is required.';
  } else {
    if (!fullName.trim()) return 'Full Name is required.';
  }
  if (!phone.trim()) return 'Mobile Number is required.';  // only checks non-empty
  return null;
};
pages/Contact.jsx · lines 73-79
const handleSubmit = async (e) => {
  e.preventDefault();
  setLoading(true);
  await base44.entities.ContactInquiry.create({ ...form, status: 'new' });
  setLoading(false);
  setSubmitted(true);
};
functions/syncCustomerToERP.js · lines 15-19
payload = await req.json();
const { customerType, companyName, contactPerson, email, phone, businessType } = payload;

if (!companyName || !email) {
  return Response.json({ error: 'companyName and email are required' }, { status: 400 });
}

Why It Is Vulnerable

    Exploitation Scenario

    User submits registration with phone: "'; SELECT * FROM users; --" or companyName: "<script>alert(1)</script>". Both pass !phone.trim() check. Data is stored in database, written to SyncLog, sent to ERPNext as-is.

    Recommended Fix

    Add backend validation: const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw 'Invalid email'; } if (companyName.length > 150) { throw 'Company name too long'; } if (phone && !/^\+?[\d\s\-()\/\.]{7,25}$/.test(phone)) { throw 'Invalid phone'; }
    HR-5HIGHCONFIRMED

    ERP API Response Validation — Silent Failures

    Active vulnerability — error responses silently skipped, code continues as if success

    Affected Files

    functions/syncCustomerToERP.js · lines 55-61, 70-76
    const emailRes = await fetch(`${erpUrl}/api/resource/Customer?${emailParams}`, {
      headers: { Authorization: authHeader },
    });
    if (emailRes.ok) {
      const d = await emailRes.json();
      if (d.data?.length > 0) erpCustomerId = d.data[0].name;
    }
    // if !emailRes.ok, the block is silently skipped
    // erpCustomerId stays null, code proceeds as if no duplicate exists
    functions/retrySyncCustomer.js · lines 58-66
    const emailRes = await fetch(`${erpUrl}/api/resource/Customer?${emailParams}`, {
      headers: { Authorization: authHeader },
    });
    if (emailRes.ok) {
      const d = await emailRes.json();
      if (d.data?.length > 0) erpCustomerId = d.data[0].name;
    }
    // same silent failure pattern

    Why It Is Vulnerable

    • if (emailRes.ok) only checks 200-299 status codes
    • ERP timeout (5xx), auth failure (401), or rate limit (429) all return false
    • No explicit error thrown — code silently continues
    • No structured response validation for success cases
    • Raw ERP error responses (containing potentially sensitive data) stored verbatim in SyncLog

    Exploitation Scenario

    ERPNext is under maintenance (503). All duplicate checks silently fail. Every new registration creates duplicate Customer record. Over time, ERP system is polluted with thousands of duplicates, degrading business data integrity and causing billing/invoice routing errors.

    Recommended Fix

    Replace silent failure: if (!emailRes.ok) { throw new Error(`ERP duplicate check failed: HTTP ${emailRes.status}`); } const emailData = await emailRes.json(); if (!emailData.data || !Array.isArray(emailData.data)) { throw new Error('ERP returned unexpected response'); } if (emailData.data.length > 0) erpCustomerId = emailData.data[0].name;
    1