Skip to content

[Security] 10 findings (8 High, 1 Medium, 1 Low) #2

Description

@pvz122

Security report for now-sh/api

Hello maintainers,

I am a security researcher studying security risks in vibe-coded software. During this research, I reviewed this repository and identified the findings below. These findings were identified in commit a5c2de9. Each finding has undergone human analysis, but the report may still contain mistakes or incomplete interpretations. Please review this report and apply the necessary security fixes.

This report contains 10 confirmed findings identified during security review. Validation details below distinguish code evidence from runtime observations and note any limitations.

Summary

ID Severity Category Affected area
SEC-001 High Authentication Failures api/index.js:26
SEC-002 High Authentication Failures api/controllers/auth.js
SEC-003 High Injection api/public/js/notes.js
api/public/js/domains.js:72–74
api/public/js/todos.js
api/public/js/utility-forms-v2.js:24–27
api/public/js/timezones.js:101–103,139,144
SEC-004 High Injection api/routes/markdownRoute.js
SEC-005 High Injection api/controllers/blog.js:189
package.json
SEC-006 High Injection api/public/js/auth.js
SEC-007 High Injection api/public/js/closings.js
SEC-008 High Broken Access Control api/routes/mainRoute.js:670–676
api/controllers/url.js:68–74
SEC-009 Medium Cryptographic Failures api/public/js/auth.js:56,97
api/public/js/auth-cookies.js:33–34
SEC-010 Low Authentication Failures api/controllers/auth.js

1. Replace jwt.decode with jwt.verify to Enforce Signature Validation

ID: SEC-001
Severity: High
Category: Authentication Failures
Affected code: api/index.js:26

Impact

Requests can trust attacker-controlled JWT claims because the token signature is not validated. This can cause forged identity information to be treated as authenticated data, although the supplied runtime check did not show impact in the current version page.

Technical details

  • api/index.js:26 calls jwt.decode(authToken) rather than verifying the token signature.
  • The decoded username, name, or sub claim is assigned to currentUser and used to build authentication display data.

Relevant code

api/index.js:20-32

  const authToken = req.cookies?.authToken || req.header('Authorization')?.replace('Bearer ', '');
  let currentUser = null;
  
  if (authToken) {
    try {
      const jwt = require('jsonwebtoken');
      const decoded = jwt.decode(authToken);
      currentUser = decoded?.username || decoded?.name || decoded?.sub || 'Authenticated User';
    } catch (_error) {
      // Invalid token, leave currentUser as null
    }
  }
  

Validation

Code evidence

  • api/index.js:20–32 extracts the bearer token and calls jwt.decode(authToken) without a secret or signature validation.
    Runtime evidence

  • A forged alg:none token with username=admin_forged_test returned Auth: "no" from /api/v1/version.
    Limitations

  • version.ejs does not render the affected versionData.Auth value; the current UI display impact was therefore not demonstrated.

Recommended remediation

Use jwt.verify() with the configured secret and enforce an allowed algorithm. Reject tokens that fail signature or claim validation before trusting their contents.

2. Set Expiry on Issued JWTs to Prevent Indefinitely Valid Tokens

ID: SEC-002
Severity: High
Category: Authentication Failures
Affected code: api/controllers/auth.js

Impact

Issued JWTs do not have a time-based expiration. A stolen token can remain usable indefinitely unless the associated database revocation record is successfully consulted and retained.

Technical details

  • api/controllers/auth.js:17–20 calls JWT.sign({ email }, process.env.JWT_SECRET) without an expiresIn option.
  • The same token-generation function is used by signup, login, and token-rotation paths.

Relevant code

api/controllers/auth.js:1-30

const bcrypt = require('bcryptjs');
const JWT = require('jsonwebtoken');
const User = require('../models/user');
const Token = require('../models/token');



/**
 * Generate JWT token (no expiration)
 */
const generateToken = (email) => {
  if (!process.env.JWT_SECRET) {
    throw new Error('JWT_SECRET is not configured');
  }
  
  // Sign without expiresIn option - token will never expire
  return JWT.sign(
    { email },
    process.env.JWT_SECRET
  );
};

/**
 * Save token to database
 */
const saveToken = async (token, userId, email, description) => {
    await Token.create({
    token,
    userId,
    email,

Validation

Code evidence

  • The function comment states Generate JWT token (no expiration), and the signing call omits expiration claims.
    Limitations

  • The supplied live probe redirected, so this finding is based on code evidence.

Recommended remediation

Set a bounded JWT lifetime when signing tokens and retain the existing revocation check as a secondary control.

3. Escape API-Sourced Fields Before Injecting into innerHTML Across Multiple Client Scripts

ID: SEC-003
Severity: High
Category: Injection
Affected code: api/public/js/notes.js, api/public/js/domains.js:72–74, api/public/js/todos.js, api/public/js/utility-forms-v2.js:24–27, api/public/js/timezones.js:101–103,139,144

Impact

API-controlled or externally supplied values can be interpreted as HTML and potentially execute script in users' browsers when rendered through innerHTML.

Technical details

  • notes.js:196–213 inserts note.title, note.category, and a preview derived from note.content into container.innerHTML without escaping.
  • domains.js:72–74 inserts domain, and utility-forms-v2.js:26 inserts message, also without the available escapeHtml() helper.
  • Sibling todos.js and blogs.js paths use escapeHtml() for comparable values.

Relevant code

api/public/js/notes.js:1-30

let notes = [];
let currentNoteId = null;

document.addEventListener('DOMContentLoaded', function() {
    // Check authentication
    checkAuth();
    
    // Event listeners
    document.getElementById('newNoteBtn').addEventListener('click', createNewNote);
    document.getElementById('saveNoteBtn').addEventListener('click', saveNote);
    document.getElementById('deleteNoteBtn').addEventListener('click', deleteCurrentNote);
    
    // Toolbar buttons
    document.querySelectorAll('.toolbar-btn').forEach(btn => {
        btn.addEventListener('click', function() {
            const format = this.getAttribute('data-format');
            formatText(format);
        });
    });
    
    // Auto-save on content change
    let saveTimer;
    document.getElementById('noteContent').addEventListener('input', function() {
        clearTimeout(saveTimer);
        if (currentNoteId) {
            saveTimer = setTimeout(() => saveNote(true), 2000);
        }
    });
    
    document.getElementById('noteTitle').addEventListener('input', function() {

Validation

Code evidence

  • The affected template literals assign API-derived fields directly to innerHTML; no escaping or safe DOM API is shown.
    Limitations

  • The deployed host returned HTTP 308 and live confirmation was not possible.

Recommended remediation

Escape every untrusted value before HTML interpolation, using the existing escapeHtml() helper, or replace innerHTML with textContent and safe DOM construction.

4. HTML-Encode Markdown Metadata Title to Prevent Reflected XSS

ID: SEC-004
Severity: High
Category: Injection
Affected code: api/routes/markdownRoute.js

Impact

A crafted Markdown heading can break out of the HTML <title> context in the preview response and inject script into users' browsers.

Technical details

  • markdownRoute.js:97 extracts the title from a raw H1 heading.
  • markdownRoute.js:277 interpolates metadata.title directly into <title>${metadata.title}</title> without HTML encoding.
  • The body is sanitized separately, so that sanitization does not protect the title context.

Relevant code

api/routes/markdownRoute.js:1-30

const express = require('express');
const cors = require('cors');
const { marked } = require('marked');
const hljs = require('highlight.js');
const { setStandardHeaders } = require('../utils/standardHeaders');

// DOMPurify with fallback for serverless environments
let DOMPurify;
try {
  DOMPurify = require('isomorphic-dompurify');
} catch {
  // Fallback: basic HTML escaping if DOMPurify fails to load
  DOMPurify = {
    sanitize: (html, options) => {
      // Allow specified tags, escape others
      const allowedTags = options?.ALLOWED_TAGS || ['p', 'br', 'strong', 'em', 'code', 'pre'];
      const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/gi;
      return html.replace(tagRegex, (match, tag) => {
        return allowedTags.includes(tag.toLowerCase()) ? match : '';
      });
    }
  };
  console.warn('DOMPurify fallback active - isomorphic-dompurify not available');
}

const markdownRoute = express.Router();

// Configure marked v4 with highlight
marked.setOptions({
  breaks: true,

Validation

Code evidence

  • The title value is derived from request Markdown and inserted into the response without encoding.
    Runtime evidence

  • A POST to /api/v1/tools/markdown/preview with a closing-title and script payload returned the payload inside the response title context.

Recommended remediation

HTML-encode the extracted title before inserting it into the <title> element, while retaining body sanitization.

5. Sanitize Markdown-Rendered HTML in Blog Controller Before Output

ID: SEC-005
Severity: High
Category: Injection
Affected code: api/controllers/blog.js:189, package.json

Impact

GitHub-sourced Markdown can introduce executable HTML into blog pages because rendered content is emitted without sanitization.

Technical details

  • api/controllers/blog.js:189 assigns post.contentHtml = marked.parse(post.content) without calling DOMPurify.
  • api/views/pages/data/blog-post.ejs:46 emits post.contentHtml with the raw EJS operator <%- ... %>.
  • The supplied code evidence identifies [email protected] as the parser version.

Relevant code

api/controllers/blog.js:183-195

const getBlogPost = async (slug) => {
  const posts = await getBlogPosts();
  const post = posts.find(post => post.slug === slug);

  if (post && post.content) {
    // Convert markdown content to HTML
    post.contentHtml = marked.parse(post.content);
  }

  return post;
};

/**

Validation

Code evidence

  • The blog controller renders Markdown directly, and the EJS template outputs the resulting HTML verbatim.
    Runtime evidence

  • The supplied probe for /data/blogs/MCU-order confirmed blog content was rendered as unescaped HTML.
    Limitations

  • The supplied evidence does not establish whether a particular live post currently contains an executable script.

Recommended remediation

Sanitize the output of marked.parse() with the existing DOMPurify dependency before assigning contentHtml, and address the reported outdated parser dependency.

6. Escape JWT Claim Values Before Setting innerHTML in Auth Page

ID: SEC-006
Severity: High
Category: Injection
Affected code: api/public/js/auth.js

Impact

HTML in JWT claims can execute when token details are displayed, allowing a forged or compromised same-origin token to trigger script in the auth page.

Technical details

  • api/public/js/auth.js:269–288 interpolates payload.userId and payload.email into a template assigned to tokenDetails.innerHTML.
  • The assignment uses neither textContent nor an escaping function.

Relevant code

api/public/js/auth.js:1-30

document.addEventListener('DOMContentLoaded', function() {
    // Handle form toggle links
    document.addEventListener('click', function(e) {
        if (e.target.matches('[data-toggle-form]')) {
            e.preventDefault();
            const formType = e.target.getAttribute('data-toggle-form');
            toggleForms(formType);
        }
        
        // Handle logout button
        if (e.target.matches('[data-action="logout"]')) {
            logout();
        }
        
        // Handle copy token button
        if (e.target.matches('[data-action="copy-token"]')) {
            copyToken();
        }
        
        // Handle edit profile button
        if (e.target.matches('[data-action="edit-profile"]')) {
            showEditProfile();
        }
        
        // Handle cancel edit button
        if (e.target.matches('[data-action="cancel-edit"]')) {
            showProfile();
        }
    });
    

Validation

Code evidence

  • JWT payload fields are inserted directly into the innerHTML template without sanitization.
    Runtime evidence

  • The signup endpoint rejected an HTML-formatted email, returning an invalid-email error.
    Limitations

  • Server-side email validation mitigates the registration vector, but does not protect against localStorage poisoning through another same-origin XSS.

Recommended remediation

Render JWT claim values with textContent, or HTML-escape each value before interpolation into any HTML context.

7. Escape Third-Party Closings Data Before Injecting into innerHTML

ID: SEC-007
Severity: High
Category: Injection
Affected code: api/public/js/closings.js

Impact

Malicious or compromised closings data can be rendered as HTML or script in every visitor's browser.

Technical details

  • api/public/js/closings.js:115–136 assigns a template literal containing closing.organization, closing.name, closing.region, closing.delay_time, and closing.reason to closingsList.innerHTML.
  • No escaping is applied in this file, despite escapeHtml() being used by sibling scripts.

Relevant code

api/public/js/closings.js:1-30

document.addEventListener('DOMContentLoaded', function() {
    const closingsList = document.getElementById('closingsList');
    const searchInput = document.getElementById('searchInput');
    const filterButtons = document.querySelectorAll('.filter-btn');
    const weatherAlert = document.getElementById('weatherAlert');
    const lastUpdated = document.getElementById('lastUpdated');
    
    let allClosings = [];
    let currentFilter = 'all';
    
    // Load closings on page load
    loadClosings();
    
    // Update every minute
    setInterval(loadClosings, 60000);
    
    // Search functionality
    searchInput.addEventListener('input', (e) => {
        filterAndDisplayClosings(e.target.value.toLowerCase(), currentFilter);
    });
    
    // Filter buttons
    filterButtons.forEach(btn => {
        btn.addEventListener('click', () => {
            filterButtons.forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            currentFilter = btn.getAttribute('data-filter');
            filterAndDisplayClosings(searchInput.value.toLowerCase(), currentFilter);
        });
    });

Validation

Code evidence

  • External closings fields flow directly into an innerHTML sink without escaping.
    Runtime evidence

  • GET /api/v1/world/closings returned empty arrays during the supplied probe.
    Limitations

  • The empty response prevented live demonstration with a malicious closings value; the vulnerable rendering path remains reachable.

Recommended remediation

Apply escapeHtml() to every closings field before interpolation, or construct the output with safe DOM APIs and textContent.

8. Block Private and Loopback Addresses in URL Shortener to Prevent SSRF and Open Redirect

ID: SEC-008
Severity: High
Category: Broken Access Control
Affected code: api/routes/mainRoute.js:670–676, api/controllers/url.js:68–74

Impact

The unauthenticated URL shortener accepts redirects to loopback, private, and link-local destinations, enabling open redirects and potentially facilitating server-side requests to internal services by consumers or integrations.

Technical details

  • api/controllers/url.js:68–74 validates the destination syntactically with new URL() but does not block private, loopback, or link-local addresses.
  • api/routes/mainRoute.js:675 redirects directly to url.originalUrl.
  • The route's isURL() validation also permits these destinations.

Relevant code

api/controllers/url.js:1-30

const crypto = require('crypto');
const Url = require('../models/url');
const { createRepository } = require('../utils/databaseUtils');
const { getExpirationLabel, formatLastUpdated, getRelativeTimeLabel } = require('../utils/dateUtils');
const { formatCompact } = require('../utils/numberUtils');

/**
 * Create URL repository
 */
const urlRepo = createRepository(Url, {
  ownerField: 'owner',
  publicField: 'isActive', // URLs are "public" if active
  defaultSort: { createdAt: -1 },
  defaultLimit: 50
});

/**
 * Generate short code for URL
 */
const generateShortCode = (length = 6) => {
  const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let code = '';
  const randomBytes = crypto.randomBytes(length);

  for (let i = 0; i < length; i++) {
    code += charset[randomBytes[i] % charset.length];
  }

  return code;
};

Validation

Code evidence

  • No destination blocklist or scheme restriction is shown before the redirect.
    Runtime evidence

  • A POST creating a short URL for http://169.254.169.254/latest/meta-data/ returned HTTP 200 and a short URL.
    Limitations

  • The supplied evidence demonstrates acceptance and redirect behavior, not a successful metadata-service retrieval by this application.

Recommended remediation

At creation time, allow only intended schemes and reject localhost, loopback, link-local, RFC-1918, and other private or reserved address ranges. Revalidate destinations before redirecting.

9. Migrate Authentication Token Storage from localStorage to HttpOnly Cookies

ID: SEC-009
Severity: Medium
Category: Cryptographic Failures
Affected code: api/public/js/auth.js:56,97, api/public/js/auth-cookies.js:33–34

Impact

JWTs stored in JavaScript-accessible storage can be exfiltrated by any XSS executing on the origin. The cookie migration does not remove this exposure because tokens remain in localStorage and the cookie is set client-side.

Technical details

  • api/public/js/auth.js:56 stores login tokens with localStorage.setItem('authToken', result.data.token); the signup path at line 97 does the same.
  • auth-cookies.js also writes the token to localStorage and sets the cookie through document.cookie, so it is not HttpOnly.
  • getToken() reads from the cookie or localStorage.

Relevant code

api/public/js/auth.js:50-62

                    body: JSON.stringify(data)
                });
                
                const result = await response.json();
                
                if (response.ok && result.success) {
                    localStorage.setItem('authToken', result.data.token);
                    showAuthenticatedState(result.data.token, result.data.user);
                    showMessage('Login successful!', 'success');
                } else {
                    const errorMessage = result.errors && result.errors.length > 0 
                        ? result.errors.map(err => err.msg).join(', ')
                        : result.message || 'Login failed';

Validation

Code evidence

  • The supplied code shows concurrent localStorage writes and a localStorage fallback after the cookie migration.
    Limitations

  • No live token-exfiltration probe was supplied.

Recommended remediation

Move token storage to server-set cookies with HttpOnly, Secure, and SameSite=Strict attributes, then remove all localStorage token reads and writes.

10. Eliminate Timing-Based Email Enumeration in Login by Running bcrypt on Missing Accounts

ID: SEC-010
Severity: Low
Category: Authentication Failures
Affected code: api/controllers/auth.js

Impact

Login response timing can reveal whether an email corresponds to an account, enabling user enumeration through repeated measurements.

Technical details

  • api/controllers/auth.js:109–111 throws immediately when User.findOne({ email }) returns no user.
  • bcrypt.compare() at line 114 runs only for existing users, adding password-hashing work to that path.

Relevant code

api/controllers/auth.js:103-117

 * Login user
 */
const login = async (email, password) => {
  // Find user
    const user = await User.findOne({ email });
  
  if (!user) {
    throw new Error('Invalid credentials');
  }
  
  // Check password
  const isMatch = await bcrypt.compare(password, user.password);
  
  if (!isMatch) {
    throw new Error('Invalid credentials');

Validation

Code evidence

  • The missing-user branch performs no bcrypt comparison, while the existing-user branch does.
    Runtime evidence

  • A nonexistent-account login returned in approximately 0.66 seconds; the supplied analysis identifies an additional approximately 100 ms bcrypt delay for an existing account with a wrong password.
    Limitations

  • The runtime measurement was network-dominated, and repeated comparative sampling was not supplied.

Recommended remediation

When no user is found, perform a bcrypt comparison against a fixed dummy hash before returning the same generic authentication error.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions