Context: Live API Exposure

The API at api.workshop17.co.za runs JXP v2.0.0 with 88 auto-generated REST endpoints. Every model — including sensitive ones like user, invoice, ledger, apikey, and payment — has full CRUD endpoints exposed. The security issues found in the code review are actively exploitable against this live API.

Key risk: The JXP framework auto-exposes /api/{model} for all 88 models. Combined with unverified JWT tokens and no input validation, an attacker with a forged token could read, modify, or delete data across the entire system — including financial records, user credentials, and payment tokens.
SprintFocusEffortTimeline
Sprint 1Critical — auth bypass & data exposure~16 hoursThis week
Sprint 2High — injection, traversal, session hardening~16 hoursNext 2 weeks
Sprint 3Moderate — infrastructure, monitoring, hardening~20 hoursNext month

Total: ~52 hours / R41,600 at R800/hr. Claude Code assists with all fixes.

Sprint 1: Critical Auth & Data Exposure

~16 hrs This week 4 fixes
Critical

Fix 1.1: Replace jwt.decode() with jwt.verify()

~1 hour packages/openmembers/routes/login.js

Problem: JWT tokens are decoded without signature verification. Anyone can forge a token and authenticate as any user.

API impact: A forged JWT gives access to all 88 endpoints at api.workshop17.co.za — including /api/user, /api/invoice, /api/payment, /api/ledger, and /api/apikey. An attacker could extract all API keys, financial data, and user credentials.
  1. Open packages/openmembers/routes/login.js
  2. Find all jwt.decode(token, config.shared_secret) (lines ~167, 405, 440)
  3. Replace each with jwt.verify(token, config.shared_secret) inside try/catch:
    try {
        const decoded = jwt.verify(token, config.shared_secret);
        req.session.user = decoded.user;
        req.session.apikey = decoded.apikey;
    } catch (err) {
        return res.status(401).send("Invalid or expired token");
    }
  4. Test: valid token works, modified token returns 401
  5. Deploy to staging, verify login flow, deploy to production
Critical

Fix 1.2: Restrict sensitive API model access

~4 hours JXP framework config / API middleware

Problem: The JXP framework auto-exposes CRUD endpoints for ALL 88 models. Several models should never be publicly accessible, even to authenticated users.

Models to restrict immediately: apikey (leaks all API keys), config (leaks system secrets), instancesettings (leaks integration credentials), schedule (contains eval'd code), token, refreshtoken, handovertoken (leaks auth tokens), payfasttoken (leaks payment credentials).
  1. Add middleware to block public access to sensitive models. In the API server setup:
    const RESTRICTED_MODELS = [
        'apikey', 'config', 'instancesettings',
        'schedule', 'token', 'refreshtoken',
        'handovertoken', 'payfasttoken'
    ];
    
    RESTRICTED_MODELS.forEach(model => {
        app.all(`/api/${model}*`, (req, res) => {
            if (!req.user || !req.user.admin) {
                return res.status(403).json({
                    error: 'Access denied'
                });
            }
            next();
        });
    });
  2. Verify that the /docs/model/ pages don't expose sensitive schema details to unauthenticated users
  3. Add admin-only middleware to /aggregate and /query routes (these accept raw MongoDB queries and can bypass filters)
  4. Test: unauthenticated request to /api/apikey returns 403
Critical

Fix 1.3: Migrate deprecated crypto functions

~8 hours 6 files + data migration

Problem: crypto.createCipher()/createDecipher() use weak key derivation. Encrypted PINs, session tokens, and user data stored via the user and pin models are vulnerable.

API models affected: user (pin, wifi_password fields), pin model. If an attacker reads encrypted values via GET /api/user, the weak encryption can be broken offline.
  1. Create shared packages/openmembers/libs/crypto-util.js:
    const crypto = require('crypto');
    const ALGO = 'aes-256-cbc';
    
    function deriveKey(secret) {
        return crypto.scryptSync(secret, 'proximity-green-salt', 32);
    }
    
    function encrypt(text, secret) {
        const key = deriveKey(secret);
        const iv = crypto.randomBytes(16);
        const cipher = crypto.createCipheriv(ALGO, key, iv);
        let enc = cipher.update(text, 'utf8', 'hex');
        enc += cipher.final('hex');
        return iv.toString('hex') + ':' + enc;
    }
    
    function decrypt(text, secret) {
        const key = deriveKey(secret);
        const [ivHex, encHex] = text.split(':');
        const decipher = crypto.createDecipheriv(ALGO, key,
            Buffer.from(ivHex, 'hex'));
        let dec = decipher.update(encHex, 'hex', 'utf8');
        dec += decipher.final('utf8');
        return dec;
    }
    
    function decryptLegacy(text, secret) {
        const d = crypto.createDecipher('aes-128-cbc', secret);
        let dec = d.update(text, 'hex', 'utf8');
        dec += d.final('utf8');
        return dec;
    }
    
    module.exports = { encrypt, decrypt, decryptLegacy };
  2. Update all 6 files: routes/login.js, libs/pin.js, libs/mail.js, libs/hbshelpers.js, routes/admin/helpers.js, models/user_model.js
  3. Write migration script to re-encrypt all stored PINs
  4. Run on staging first, verify decrypt works, then run on production in low-traffic window
High

Fix 1.4: Stop storing plaintext passwords in sessions

~3 hours packages/openmembers/routes/login.js

Problem: Plaintext passwords encrypted reversibly in the session. If the encryption key leaks (see Fix 1.3), all passwords are exposed.

  1. Store only the API key in the session — not the password:
    // BEFORE (insecure)
    req.session.encrypted = encrypt(JSON.stringify({
        email: req.body.email,
        password: req.body.password
    }));
    
    // AFTER
    req.session.apikey = loginResult.apikey;
    req.session.user = loginResult.user;
  2. Update all code paths reading req.session.encrypted
  3. Update apihelper to use stored API key for authentication
  4. After deploy, flush Redis session store to force re-login: redis-cli FLUSHDB

Sprint 2: Injection, Traversal & Session Hardening

~16 hrs Next 2 weeks 5 fixes
Medium

Fix 2.1: Patch path traversal in image handler

~1 hour packages/openmembers/routes/image.js

Problem: decodeURIComponent(filename) allows ../ traversal to read arbitrary server files including config with secrets.

  1. Replace the fname function with path validation:
    const fname = (filename, upload_dir) => {
        const decoded = decodeURIComponent(filename)
            .replace("/uploads/", "");
        const base = decoded.startsWith("/")
            ? path.join(process.cwd(), "public")
            : upload_dir;
        const resolved = path.resolve(base, decoded);
        if (!resolved.startsWith(path.resolve(base))) {
            throw new Error("Invalid file path");
        }
        return resolved;
    };
  2. Wrap handler in try/catch, return 400 for invalid paths
  3. Test: /image/..%2F..%2Fetc%2Fpasswd must return 400
Medium

Fix 2.2: Escape shell parameters in Papercut

~2 hours packages/openmembers/libs/papercut.js

Problem: Usernames concatenated into shell commands. A user record with a malicious papercut_username could trigger command injection on the Papercut server.

API vector: If PUT /api/user/{id} allows setting papercut_username, an attacker could inject via the API. Validate this field at the model level.
  1. Install shell-escape: yarn add shell-escape
  2. Replace serverCmdFormat to use shellescape(parts) instead of parts.join(" ")
  3. Add username format validation: /^[a-zA-Z0-9._@-]+$/
  4. Add the same validation to the user model's papercut_username field in the Mongoose schema
Medium

Fix 2.3: Add session cookie security flags

~1 hour packages/openmembers/app.js

Problem: Session cookies missing secure, httpOnly, sameSite flags.

  1. Update session config:
    app.use(session({
        secret: config.secret,
        store: new RedisStore({}),
        resave: false,
        saveUninitialized: false,
        cookie: {
            secure: process.env.NODE_ENV === 'production',
            httpOnly: true,
            sameSite: 'lax',
            maxAge: 24 * 60 * 60 * 1000
        }
    }));
  2. Set app.set('trust proxy', 1) for Nginx
Medium

Fix 2.4: Enable TLS certificate validation

~2 hours config/production.json

Problem: "rejectUnauthorized": false disables TLS validation on all outbound HTTPS to Xero, PayFast, BulkSMS, etc.

  1. Set "rejectUnauthorized": true in production config (or remove the tls section entirely)
  2. Remove any NODE_TLS_REJECT_UNAUTHORIZED = '0'
  3. Test all outbound integrations on staging: Xero token refresh, PayFast payments, BulkSMS
  4. If a specific internal service uses self-signed certs, scope the exception to only that connection
Medium

Fix 2.5: Strengthen brute force & add API rate limiting

~4 hours packages/openmembers/routes/login.js + API server

Problem: 1-second wait after 5 failed logins. No rate limiting on the API layer.

API impact: The /query and /aggregate endpoints accept raw MongoDB queries. Without rate limiting, an attacker could run expensive aggregation pipelines to DoS the database, or enumerate data via repeated filtered queries.
  1. Update express-brute: 3 retries, 5-second min wait, 15-minute max, 1-hour window
  2. Add express-rate-limit to the API server: 100 requests/15 min per IP
  3. Add stricter limits on /query and /aggregate routes: 20 requests/15 min
  4. Log all blocked requests with IP and target endpoint for monitoring

Sprint 3: Infrastructure & Hardening

~20 hrs Next month 5 fixes
Moderate

Fix 3.1: Update Docker images to Node 20

~6 hours Dockerfile-api, packages/openmembers/Dockerfile

Problem: node:carbon (Node 8, EOL 2019) and node:16 (EOL 2023) have known CVEs. Containers run as root.

  1. Update both Dockerfiles to node:20-slim
  2. Add non-root user: RUN groupadd -r app && useradd -r -g app app and USER app
  3. Add HEALTHCHECK directive
  4. Test all services on staging for Node 20 compatibility
  5. If OpenSSL issues, add NODE_OPTIONS=--openssl-legacy-provider as temporary bridge
Moderate

Fix 3.2: Sanitise config & secrets management

~2 hours config/sample.json, systemd services

Problem: Hardcoded secrets in sample config, SSH keys referenced from config files.

  1. Replace all secrets in config/sample.json with CHANGE_ME_* placeholders
  2. Add startup validation that rejects placeholder values
  3. Move SSH key paths to environment variables in systemd service files
  4. Ensure key files have chmod 600
  5. Verify production config is in .gitignore
  6. Rotate production secrets if they match sample values
Moderate

Fix 3.3: Add input validation to critical API routes

~7 hours packages/openmembers/routes/*.js + API models

Problem: No input validation. req.body goes directly to MongoDB. The POST /api/{model} and PUT /api/{model}/{id} endpoints accept arbitrary JSON.

API risk: Since JXP auto-generates POST /api/user, PUT /api/invoice, etc., an attacker could inject unexpected fields including admin: true on user records or manipulate amount fields on financial models without validation.
  1. Install express-validator: yarn add express-validator
  2. Add validation to the 5 highest-risk OpenMembers routes: login, organisation update, booking create, lead create, user update
  3. For the JXP API layer, add a global middleware that strips dangerous fields from POST/PUT:
    const PROTECTED_FIELDS = ['admin', '_owner_id', '_deleted',
        'password', 'temp_hash', 'apikey'];
    
    app.use('/api', (req, res, next) => {
        if (['POST', 'PUT'].includes(req.method) && req.body) {
            PROTECTED_FIELDS.forEach(f => delete req.body[f]);
        }
        next();
    });
  4. Allow admin and password fields only from requests with super_user role
Moderate

Fix 3.4: Disable docs endpoint in production

~1 hour API server config

Problem: The /docs/model/{name} endpoints expose full schema details for all 88 models including field types, indexes, and permissions. This is an information disclosure risk in production.

  1. Add environment check to disable docs in production:
    if (process.env.NODE_ENV === 'production') {
        app.all('/docs/*', (req, res) => {
            res.status(404).send('Not found');
        });
    }
  2. Alternatively, restrict docs to authenticated admin users only
  3. The docs at api.workshop17.co.za remain useful for internal reference — restrict by IP or auth, don't remove entirely
Moderate

Fix 3.5: Add security monitoring

~4 hours API server + Sentry

Problem: Sentry is configured for error tracking but there's no security-specific monitoring. Failed auth attempts, rate limit hits, and suspicious queries go unnoticed.

  1. Create a security logger middleware:
    function securityLog(event, req, details = {}) {
        console.warn(JSON.stringify({
            event,
            ip: req.ip,
            user: req.user?._id,
            path: req.path,
            method: req.method,
            timestamp: new Date().toISOString(),
            ...details
        }));
    }
  2. Log on: failed auth, rate limit hit, restricted model access, path traversal attempt, protected field injection
  3. Set up Sentry alerts for security event patterns (e.g. >10 failed auths from same IP in 5 min)
  4. Review logs weekly until the new platform launches

Summary

#FixSeverityEffortSprint
1.1jwt.decode() → jwt.verify()Critical1 hr1
1.2Restrict sensitive model endpointsCritical4 hrs1
1.3Migrate deprecated cryptoCritical8 hrs1
1.4Remove password from sessionHigh3 hrs1
2.1Patch path traversalMedium1 hr2
2.2Escape Papercut shell paramsMedium2 hrs2
2.3Session cookie security flagsMedium1 hr2
2.4Enable TLS validationMedium2 hrs2
2.5Brute force + API rate limitingMedium4 hrs2
3.1Docker images to Node 20Moderate6 hrs3
3.2Config & secrets cleanupModerate2 hrs3
3.3Input validation on critical routesModerate7 hrs3
3.4Disable docs in productionModerate1 hr3
3.5Security monitoringModerate4 hrs3
ItemAmount
Developer time (52 hrs @ R800/hr)R41,600
Claude Code Max (1 month)R1,850
TotalR43,450