Security Foot-Gun Analysis: micr-dev/thinko
Repository: micr-dev/thinko
Analyzed: 2026-04-16
Agent: Nightshift v3 (GLM 5.1)
P1 — Hardcoded Fallback Session Secret
File: api/_lib/config.js:20-22
The session secret falls back to a hardcoded value when SESSION_SECRET is not set:
function getSessionSecret() {
return getEnv('SESSION_SECRET', 'xp-drawings-dev-secret');
}
Risk: If deployed without SESSION_SECRET, an attacker who reads the source code can forge arbitrary admin sessions. The secret is used for HMAC-SHA256 cookie signing. Anyone who knows xp-drawings-dev-secret can create valid admin sessions and gain full admin access (approve/reject submissions, manage commissions).
Recommendation: Remove the fallback. Throw an error at startup if SESSION_SECRET is not set in production:
function getSessionSecret() {
const value = process.env.SESSION_SECRET;
if (!value && process.env.NODE_ENV === 'production') {
throw new Error('SESSION_SECRET must be set in production');
}
return value || 'dev-only-secret';
}
P1 — SSRF via Draft Image URL (Admin Endpoint)
File: api/_lib/handlers.js:112-130
The normalizeDraftImageUrl function validates that the URL origin matches getBaseUrl(req), but getBaseUrl trusts x-forwarded-proto and x-forwarded-host headers:
function getBaseUrl(req) {
const protocol = req.headers['x-forwarded-proto'] || ...;
const host = req.headers['x-forwarded-host'] || req.headers.host;
return `${protocol}://${host}`;
}
An attacker can set x-forwarded-host: evil.com and x-forwarded-proto: https, making getBaseUrl return https://evil.com. Then normalizeDraftImageUrl would allow URLs from evil.com/custom/commission-drafts/..., enabling SSRF through the server's fetch() call.
Recommendation: Validate x-forwarded-host against an allowlist of known hosts, or use a hardcoded APP_BASE_URL env var exclusively without header fallback in production.
P2 — In-Memory Rate Limiting (Serverless Reset)
File: api/_lib/handlers.js:33-53
The submission rate limiter uses an in-memory Map:
const submissionRateLimitStore = new Map();
On Vercel (serverless), each function invocation may run in a separate instance. This means the rate limit resets on every cold start and is ineffective across instances. An attacker can bypass the 5-submissions-per-10-minutes limit by sending requests that hit different function instances.
Recommendation: Use Vercel Blob, KV, or an external store for distributed rate limiting. For a lightweight fix, use the x-forwarded-for IP + timestamp stored in Vercel Blob.
P2 — No CSRF Protection on Admin Actions
Files: api/_lib/handlers.js (all admin POST handlers)
Admin actions (approve/reject submissions, upload/delete commissions) only check the session cookie via requireAdmin(). There is no CSRF token validation. A malicious website could trick an authenticated admin into performing admin actions via automatic form submission or fetch with credentials.
Note: The SameSite=Lax cookie setting mitigates this for GET-initiated navigation, but POST requests from cross-origin subresources (e.g., JavaScript fetch with credentials: include) are not protected by SameSite=Lax in all browsers.
Recommendation: Add a CSRF token to the admin session that must be included as a custom header (e.g., X-CSRF-Token) on all state-changing requests.
P2 — OAuth State Cookie Name Partially Exposed
File: api/_lib/config.js:7
const OAUTH_STATE_COOKIE = 'xp_adm...tate';
The cookie name is truncated in the source, but the pattern suggests xp_admin_oauth_state or similar. While this isn't directly exploitable, combined with the hardcoded session secret, an attacker could forge the OAuth state cookie and potentially exploit the OAuth flow.
P2 — Catbox Upload Without Size Limit
File: api/_lib/handlers.js:99-109 and api/_lib/commissions-store.js (loadCommissionImage)
The commission image upload flow downloads images from catbox.moe or accepts base64 data URLs, but the only size limit is Vercel's 10MB body parser limit. There is no validation of image dimensions or pixel count before processing with sharp. Extremely large images (e.g., 10000x10000px) could cause memory exhaustion during icon generation.
Recommendation: Add maximum dimension limits (e.g., 4096x4096px) in parseImageDimensions or after dimension parsing.
P3 — Session Expiration Not Refreshed on Activity
File: api/_lib/auth.js:119-127
Admin sessions have a fixed 7-day expiry set at login time:
const payload = {
login,
exp: now + 1000 * 60 * 60 * 24 * 7,
};
The expiry is never refreshed on subsequent admin requests. If an admin uses the panel for 6 days and then performs a critical action, their session may expire mid-operation.
Recommendation: Consider refreshing the session cookie on each authenticated request (sliding window).
P3 — Error Messages May Leak Internal State
Files: Multiple handlers
Some catch blocks expose raw error messages:
sendError(res, error.statusCode || 500, error.message || 'Submit failed');
Custom errors with statusCode and safe messages are fine, but if an unexpected error occurs (e.g., Blob storage error), the raw error message is sent to the client, potentially leaking internal paths, configuration details, or stack information.
Recommendation: In production, only expose error messages for client errors (4xx). For 5xx errors, return a generic message and log the details server-side.
P3 — ntfy Notification Without Authentication
File: api/_lib/ntfy.js
The notification is sent to ntfy.sh without any authentication:
await fetch(`${baseUrl}/${encodeURIComponent(topic)}`, { ... });
If the topic name is predictable or leaked, anyone could send fake notifications. Additionally, the topic name is sent unauthenticated, so anyone monitoring the ntfy.sh endpoint could discover it.
Recommendation: Use ntfy's access tokens or a private ntfy instance for production notifications.
P3 — No Input Sanitization for XSS in Admin Panel
Files: api/_lib/commissions-store.js (sanitizeLine, sanitizeDescription), api/_lib/drawings-store.js (sanitizeTitle)
The sanitization functions only trim whitespace and limit length. They don't sanitize HTML or JavaScript. If admin-provided fields (artistName, description, title) are rendered in the React frontend without escaping, they could enable stored XSS.
Note: React generally escapes JSX text content, so this depends on how the frontend renders these fields (e.g., dangerouslySetInnerHTML).
Recommendation: Audit the frontend rendering to confirm no dangerouslySetInnerHTML is used with user-provided content. If it is, add HTML sanitization on the server side.
Summary
| Severity |
Count |
Key Issues |
| P1 Critical |
2 |
Hardcoded session secret, SSRF via header trust |
| P2 High |
3 |
Ineffective rate limiting, No CSRF, Large image DoS |
| P3 Medium |
4 |
Session expiry, Error leakage, ntfy auth, Potential XSS |
| Total |
9 |
|
The most impactful fix would be removing the hardcoded session secret fallback and validating x-forwarded-host against an allowlist. These two changes would significantly raise the bar for attackers.
Security Foot-Gun Analysis: micr-dev/thinko
Repository: micr-dev/thinko
Analyzed: 2026-04-16
Agent: Nightshift v3 (GLM 5.1)
P1 — Hardcoded Fallback Session Secret
File:
api/_lib/config.js:20-22The session secret falls back to a hardcoded value when
SESSION_SECRETis not set:Risk: If deployed without
SESSION_SECRET, an attacker who reads the source code can forge arbitrary admin sessions. The secret is used for HMAC-SHA256 cookie signing. Anyone who knowsxp-drawings-dev-secretcan create valid admin sessions and gain full admin access (approve/reject submissions, manage commissions).Recommendation: Remove the fallback. Throw an error at startup if
SESSION_SECRETis not set in production:P1 — SSRF via Draft Image URL (Admin Endpoint)
File:
api/_lib/handlers.js:112-130The
normalizeDraftImageUrlfunction validates that the URL origin matchesgetBaseUrl(req), butgetBaseUrltrustsx-forwarded-protoandx-forwarded-hostheaders:An attacker can set
x-forwarded-host: evil.comandx-forwarded-proto: https, makinggetBaseUrlreturnhttps://evil.com. ThennormalizeDraftImageUrlwould allow URLs fromevil.com/custom/commission-drafts/..., enabling SSRF through the server'sfetch()call.Recommendation: Validate
x-forwarded-hostagainst an allowlist of known hosts, or use a hardcodedAPP_BASE_URLenv var exclusively without header fallback in production.P2 — In-Memory Rate Limiting (Serverless Reset)
File:
api/_lib/handlers.js:33-53The submission rate limiter uses an in-memory
Map:On Vercel (serverless), each function invocation may run in a separate instance. This means the rate limit resets on every cold start and is ineffective across instances. An attacker can bypass the 5-submissions-per-10-minutes limit by sending requests that hit different function instances.
Recommendation: Use Vercel Blob, KV, or an external store for distributed rate limiting. For a lightweight fix, use the
x-forwarded-forIP + timestamp stored in Vercel Blob.P2 — No CSRF Protection on Admin Actions
Files:
api/_lib/handlers.js(all admin POST handlers)Admin actions (approve/reject submissions, upload/delete commissions) only check the session cookie via
requireAdmin(). There is no CSRF token validation. A malicious website could trick an authenticated admin into performing admin actions via automatic form submission or fetch with credentials.Note: The SameSite=Lax cookie setting mitigates this for GET-initiated navigation, but POST requests from cross-origin subresources (e.g., JavaScript fetch with
credentials: include) are not protected by SameSite=Lax in all browsers.Recommendation: Add a CSRF token to the admin session that must be included as a custom header (e.g.,
X-CSRF-Token) on all state-changing requests.P2 — OAuth State Cookie Name Partially Exposed
File:
api/_lib/config.js:7The cookie name is truncated in the source, but the pattern suggests
xp_admin_oauth_stateor similar. While this isn't directly exploitable, combined with the hardcoded session secret, an attacker could forge the OAuth state cookie and potentially exploit the OAuth flow.P2 — Catbox Upload Without Size Limit
File:
api/_lib/handlers.js:99-109andapi/_lib/commissions-store.js(loadCommissionImage)The commission image upload flow downloads images from catbox.moe or accepts base64 data URLs, but the only size limit is Vercel's 10MB body parser limit. There is no validation of image dimensions or pixel count before processing with
sharp. Extremely large images (e.g., 10000x10000px) could cause memory exhaustion during icon generation.Recommendation: Add maximum dimension limits (e.g., 4096x4096px) in
parseImageDimensionsor after dimension parsing.P3 — Session Expiration Not Refreshed on Activity
File:
api/_lib/auth.js:119-127Admin sessions have a fixed 7-day expiry set at login time:
The expiry is never refreshed on subsequent admin requests. If an admin uses the panel for 6 days and then performs a critical action, their session may expire mid-operation.
Recommendation: Consider refreshing the session cookie on each authenticated request (sliding window).
P3 — Error Messages May Leak Internal State
Files: Multiple handlers
Some catch blocks expose raw error messages:
Custom errors with
statusCodeand safe messages are fine, but if an unexpected error occurs (e.g., Blob storage error), the raw error message is sent to the client, potentially leaking internal paths, configuration details, or stack information.Recommendation: In production, only expose error messages for client errors (4xx). For 5xx errors, return a generic message and log the details server-side.
P3 — ntfy Notification Without Authentication
File:
api/_lib/ntfy.jsThe notification is sent to ntfy.sh without any authentication:
If the topic name is predictable or leaked, anyone could send fake notifications. Additionally, the topic name is sent unauthenticated, so anyone monitoring the ntfy.sh endpoint could discover it.
Recommendation: Use ntfy's access tokens or a private ntfy instance for production notifications.
P3 — No Input Sanitization for XSS in Admin Panel
Files:
api/_lib/commissions-store.js(sanitizeLine, sanitizeDescription),api/_lib/drawings-store.js(sanitizeTitle)The sanitization functions only trim whitespace and limit length. They don't sanitize HTML or JavaScript. If admin-provided fields (artistName, description, title) are rendered in the React frontend without escaping, they could enable stored XSS.
Note: React generally escapes JSX text content, so this depends on how the frontend renders these fields (e.g.,
dangerouslySetInnerHTML).Recommendation: Audit the frontend rendering to confirm no
dangerouslySetInnerHTMLis used with user-provided content. If it is, add HTML sanitization on the server side.Summary
The most impactful fix would be removing the hardcoded session secret fallback and validating
x-forwarded-hostagainst an allowlist. These two changes would significantly raise the bar for attackers.