Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"eslint": "^8.57.0",
"prettier": "^3.2.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0",
"vitest": "^1.6.1",
"wrangler": "^4.54.0"
},
"dependencies": {
Expand Down
18 changes: 18 additions & 0 deletions src/api/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { requireLinkAccess, requirePermission } from '../middleware/authorizatio
import { canAccessDomain } from '../utils/permissions';
import { isInfiniteRedirect } from '../utils/domains';
import { createLinkSchema, updateLinkSchema } from '../schemas';
import { hashPassword } from '../utils/crypto';

const linksRouter = new Hono<{ Bindings: Env }>();

Expand Down Expand Up @@ -514,6 +515,12 @@ linksRouter.post('/', authOrApiKeyMiddleware, requirePermission('create_links'),
metadata = JSON.stringify(metadataObj);
}

// Hash password if provided
let passwordHash: string | undefined;
if (validated.password) {
passwordHash = await hashPassword(validated.password);
}

// Create link
const link = await createLink(c.env, {
domain_id: validated.domain_id,
Expand All @@ -524,6 +531,7 @@ linksRouter.post('/', authOrApiKeyMiddleware, requirePermission('create_links'),
redirect_code: validated.redirect_code,
status: 'active',
expires_at: validated.expires_at,
password_hash: passwordHash,
metadata,
category_id: validated.category_id, // Use dedicated column
click_count: 0,
Expand Down Expand Up @@ -660,6 +668,16 @@ linksRouter.put('/:id', authOrApiKeyMiddleware, requireLinkAccess('edit'), valid
updates.category_id = validated.category_id;
}

// Handle password update
if (validated.password !== undefined) {
if (validated.password === '') {
// Empty string means remove password
updates.password_hash = null;
} else {
updates.password_hash = await hashPassword(validated.password);
}
}

// Handle route update
if (validated.route !== undefined) {
const domain = await getDomainById(c.env, existingLink.domain_id);
Expand Down
64 changes: 63 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { csrfProtection } from './middleware/csrf';
import { securityHeaders } from './middleware/security';
import { cacheControl } from './middleware/cache-control';
import { handleRedirect } from './services/redirect';
import { getDomainByRoutingPath } from './db/domains';
import { getDomainByRoutingPath, getDomainById } from './db/domains';
import { getLinkById } from './db/links';
import { verifyPassword, generateAuthCookie } from './utils/crypto';

// Import API routes (static - they're small and needed for functionality)
import { linksRouter } from './api/links';
Expand Down Expand Up @@ -273,6 +275,66 @@ app.route('/api/v1/categories', categoriesRouter);
app.route('/api/v1/api-keys', apiKeysRouter);
app.route('/api/v1/settings', settingsRouter);

// ============================================================================
// PASSWORD VERIFICATION HANDLER
// ============================================================================

app.post('/__verify_password__', async (c) => {
try {
Comment on lines +282 to +283
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve /verify_password slug to avoid collisions

The new app.post('/__verify_password__', ...) handler claims a public path that is still allowed as a user slug (the reserved slug list does not include __verify_password__). This means any existing or newly created link with slug __verify_password__ will now be shadowed by the verification endpoint and stop resolving to its destination. Please add this slug to RESERVED_SLUGS (or move the endpoint under /api/...) so user-defined slugs cannot collide with the password verifier route.

Useful? React with 👍 / 👎.

const body = await c.req.parseBody();
const linkId = body['link_id'] as string;
const password = body['password'] as string;
// We ignore the domain from body to prevent open redirects
// const domain = body['domain'] as string;
const slug = body['slug'] as string;
Comment on lines +284 to +289
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The domain and slug are taken directly from the request body. This creates an open redirect vulnerability. An attacker could provide a valid link_id and password but substitute a malicious domain and slug. Upon successful verification, the user would be redirected to the attacker's site. You should fetch the domain and slug from the link object retrieved from the database instead of trusting user input.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules please review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I'll review these lines.


if (!linkId || !password || !slug) {
return c.text('Missing required fields', 400);
}
Comment on lines +285 to +293
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type assertions as string for linkId, password, and slug are unsafe. c.req.parseBody() can return string | string[] | File. If a form field is sent multiple times, it will be an array, and the type assertion will not prevent runtime issues. It's better to validate the types to ensure the handler is robust against malformed requests.

    const linkId = body['link_id'];
    const password = body['password'];
    // We ignore the domain from body to prevent open redirects
    // const domain = body['domain'] as string;
    const slug = body['slug'];

    if (typeof linkId !== 'string' || typeof password !== 'string' || typeof slug !== 'string') {
       return c.text('Invalid or missing required fields', 400);
    }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I'll review this.


const link = await getLinkById(c.env, linkId);
if (!link) {
return c.text('Link not found', 404);
}

// Securely fetch domain
const domainObj = await getDomainById(c.env, link.domain_id);
if (!domainObj) {
return c.text('Domain not found', 404);
}
const domain = domainObj.domain_name;

if (!link.password_hash) {
// Link is not password protected, just redirect
const url = \`https://\${domain}/\${slug}\`;
return c.redirect(url);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const isValid = await verifyPassword(password, link.password_hash);

if (isValid) {
// Set cookie and redirect
// Calculate max-age (e.g. 1 hour)
const maxAge = 3600;

const url = \`https://\${domain}/\${slug}\`;
const authCookieValue = await generateAuthCookie(link.password_hash);

// Set cookie on the domain
c.header('Set-Cookie', \`link_access_\${linkId}=\${authCookieValue}; Path=/; Max-Age=\${maxAge}; Secure; HttpOnly; SameSite=Lax\`);

return c.redirect(url);
} else {
// Invalid password
const url = \`https://\${domain}/\${slug}?error=1\`;
return c.redirect(url);
Comment on lines +309 to +330
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Template literal syntax errors will prevent the worker from loading.

Static analysis reports parse errors due to malformed template literals. The backslash escapes before backticks and dollar signs are invalid JavaScript:

// Current (broken)
const url = \`https://\${domain}/\${slug}\`;

// Correct
const url = `https://${domain}/${slug}`;
🔎 Proposed fix for syntax errors
-      const url = \`https://\${domain}/\${slug}\`;
+      const url = `https://${domain}/${slug}`;
       return c.redirect(url);
     }
     // ...
-      const url = \`https://\${domain}/\${slug}\`;
+      const url = `https://${domain}/${slug}`;
       // ...
-      c.header('Set-Cookie', \`link_access_\${linkId}=valid; Path=/; Max-Age=\${maxAge}; Secure; HttpOnly; SameSite=Lax\`);
+      c.header('Set-Cookie', `link_access_${linkId}=valid; Path=/; Max-Age=${maxAge}; Secure; HttpOnly; SameSite=Lax`);

       return c.redirect(url);
     } else {
       // Invalid password
-      const url = \`https://\${domain}/\${slug}?error=1\`;
+      const url = `https://${domain}/${slug}?error=1`;
       return c.redirect(url);
🧰 Tools
🪛 Biome (2.1.2)

[error] 301-301: unexpected token \

(parse)


[error] 316-316: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 318-318: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 318-318: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 319-319: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 319-319: Expected an expression, or an assignment but instead found ')'.

Expected an expression, or an assignment here.

(parse)


[error] 320-320: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 321-321: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 322-322: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 324-324: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 325-325: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)

🤖 Prompt for AI Agents
In src/index.ts around lines 301 to 335, several template literals are malformed
with backslash-escaped backticks and dollar signs (e.g.
`\`https://\${domain}/\${slug}\``) causing parse errors; replace the escaped
sequences with proper JavaScript template literals (e.g.
``https://${domain}/${slug}``) for all occurrences, and ensure the Set-Cookie
header string also uses a normal template literal without backslashes (e.g.
``link_access_${linkId}=valid; Path=/; Max-Age=${maxAge}; Secure; HttpOnly;
SameSite=Lax``) so the worker can parse and load successfully.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules please review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

}
} catch (e) {
console.error('Password verification error:', e);
return c.text('Internal Server Error', 500);
}
});

// ============================================================================
// LINK REDIRECT HANDLER - Catch-all for short link redirects
// ============================================================================
Expand Down
1 change: 1 addition & 0 deletions src/schemas/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const baseLinkSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
geo_redirects: z.array(geoRedirectSchema).max(10).optional().default([]),
device_redirects: z.array(deviceRedirectSchema).optional().default([]),
password: z.string().max(100).optional(), // Plain text password input
});

// ============================================================================
Expand Down
26 changes: 26 additions & 0 deletions src/services/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { getCachedLink, setCachedLink } from './cache';
import { getLinkBySlug, incrementClickCount } from '../db/links';
import { getGeoRedirects, getDeviceRedirects } from '../db/linkRedirects';
import { trackClick, parseUserAgent, extractUtmParams, hashIpAddress, formatDateForGrouping, extractReferrerDomain } from './analytics';
import { passwordHtml } from '../views/password';
import { generateAuthCookie } from '../utils/crypto';

/**
* Merges query parameters from the request URL into the destination URL.
Expand Down Expand Up @@ -168,6 +170,30 @@ export async function handleRedirect(
return new Response('Link has expired', { status: 410 });
}

// Password Protection Check
if (cached.password_hash) {
const cookieHeader = request.headers.get('Cookie');
const authCookie = cookieHeader?.match(new RegExp(`link_access_${cached.link_id}=([^;]+)`))?.[1];
Comment on lines +175 to +176
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ReDoS vulnerability: Regular expression constructed from variable input.

As flagged by static analysis, constructing a RegExp from cached.link_id (Line 174) introduces a Regular Expression Denial of Service (ReDoS) risk if link_id contains malicious regex patterns.

🔎 Proposed fix using string methods

Replace the regex-based cookie parsing with string methods:

- const cookieHeader = request.headers.get('Cookie');
- const authCookie = cookieHeader?.match(new RegExp(`link_access_${cached.link_id}=([^;]+)`))?.[1];
+ const cookieHeader = request.headers.get('Cookie') || '';
+ const cookieName = `link_access_${cached.link_id}=`;
+ const cookies = cookieHeader.split(';').map(c => c.trim());
+ const authCookie = cookies
+   .find(c => c.startsWith(cookieName))
+   ?.substring(cookieName.length);

Alternatively, use a dedicated cookie parsing library that handles edge cases safely.

Based on static analysis hints.

🧰 Tools
🪛 ast-grep (0.40.3)

[warning] 174-174: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(link_access_${cached.link_id}=([^;]+))
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
In src/services/redirect.ts around lines 174-175, constructing a RegExp from the
variable cached.link_id creates a ReDoS risk; replace the dynamic regex with a
safe string-based cookie parse (split the Cookie header by ';', trim parts, find
the entry that starts with "link_access_<cached.link_id>=", then extract the
value after the '='), or use a well-tested cookie parsing library; if you must
use a regex, first escape cached.link_id to neutralize metacharacters, but
prefer the simple split/find approach for safety and performance.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules please review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Checking the cookie handling logic.

const expectedCookie = await generateAuthCookie(cached.password_hash);

if (authCookie !== expectedCookie) {
// Return password prompt
// We need to replace placeholders
let html = passwordHtml
.replace('{{DOMAIN}}', domain.domain_name)
.replace('{{SLUG}}', slug)
.replace('{{LINK_ID}}', cached.link_id);
Comment on lines +182 to +185
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The values for domain.domain_name, slug, and cached.link_id are directly injected into the HTML template for the password prompt page. If any of these values contain malicious HTML, it could lead to a Cross-Site Scripting (XSS) vulnerability. You should always escape values before rendering them in HTML.

You can also consider moving this escapeHtml helper to a shared utility file (e.g., src/utils/validation.ts) to be reused across the application, as a similar function is also defined in src/views/dashboard.ts.

       const escapeHtml = (unsafe: string): string => {
         if (!unsafe) return '';
         return unsafe
           .replace(/&/g, "&amp;")
           .replace(/</g, "&lt;")
           .replace(/>/g, "&gt;")
           .replace(/"/g, "&quot;")
           .replace(/'/g, "&#039;");
       };
       let html = passwordHtml
         .replace('{{DOMAIN}}', escapeHtml(domain.domain_name))
         .replace('{{SLUG}}', escapeHtml(slug))
         .replace('{{LINK_ID}}', escapeHtml(cached.link_id));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I'll double-check the cookie verification logic.


return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-store', // Don't cache the prompt
}
});
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Strict Routing Check (performed AFTER cache retrieval to ensure it applies to cached links too)
if (matchedRoute) {
const linkRoute = cached.route;
Expand Down
8 changes: 8 additions & 0 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,11 @@ export async function verifyApiKey(apiKey: string, storedHash: string): Promise<
return await verifyPassword(apiKey, storedHash);
}

// Generate auth cookie value from password hash
export async function generateAuthCookie(passwordHash: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(passwordHash + 'authenticated');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
Comment on lines +113 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The generateAuthCookie function creates a cookie value by hashing the password hash with a static string. If an attacker gains access to the database and obtains the password hashes, they can forge authentication cookies for any password-protected link. A more secure approach is to use a server-side secret (e.g., from environment variables) to sign the cookie.

This suggestion adds a secret parameter to the function. You will also need to add a COOKIE_SECRET to your environment variables and pass it to generateAuthCookie where it's called (in src/index.ts and src/services/redirect.ts), like generateAuthCookie(link.password_hash, c.env.COOKIE_SECRET).

For even stronger security, consider using HMAC (Hash-based Message Authentication Code) with crypto.subtle.sign instead of simple hashing.

Suggested change
export async function generateAuthCookie(passwordHash: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(passwordHash + 'authenticated');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
export async function generateAuthCookie(passwordHash: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(passwordHash + secret);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I'll review the cookie generation logic.

Comment on lines +112 to +119
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Major security flaw: Deterministic authentication cookie enables forgery.

The generateAuthCookie function produces a deterministic hash—the same password_hash always yields the same cookie value. This creates several vulnerabilities:

  1. Database leak = authentication bypass: If password_hash is leaked (via database breach, logs, backups), an attacker can generate valid authentication cookies for any password-protected link without knowing the actual password.
  2. No cryptographic signing: The cookie lacks a secret key or HMAC signature, so the server cannot verify it was issued by the application.
  3. Replay attacks: A cookie captured once remains valid indefinitely (until password changes).

The appended constant 'authenticated' provides no security—it's publicly known and doesn't prevent forgery.

🔎 Recommended fix: Use HMAC with a secret key

Replace the deterministic hash with an HMAC-based signed token:

-// Generate auth cookie value from password hash
-export async function generateAuthCookie(passwordHash: string): Promise<string> {
+// Generate auth cookie value from password hash using HMAC
+export async function generateAuthCookie(passwordHash: string, secret: string): Promise<string> {
   const encoder = new TextEncoder();
-  const data = encoder.encode(passwordHash + 'authenticated');
-  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
+  
+  // Import secret key for HMAC
+  const keyMaterial = await crypto.subtle.importKey(
+    'raw',
+    encoder.encode(secret),
+    { name: 'HMAC', hash: 'SHA-256' },
+    false,
+    ['sign']
+  );
+  
+  // Sign the password hash with timestamp to prevent replay
+  const timestamp = Math.floor(Date.now() / 1000);
+  const data = encoder.encode(`${passwordHash}:${timestamp}`);
+  const hashBuffer = await crypto.subtle.sign('HMAC', keyMaterial, data);
   const hashArray = Array.from(new Uint8Array(hashBuffer));
-  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+  const signature = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+  
+  // Return timestamp + signature for verification
+  return `${timestamp}:${signature}`;
 }

Then update the verification logic to:

  1. Parse timestamp from cookie
  2. Verify HMAC signature using the secret
  3. Check timestamp isn't too old (e.g., reject if > 1 hour)

You'll need to add a COOKIE_SECRET environment variable and pass it to generateAuthCookie in both src/index.ts and src/services/redirect.ts.

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I'll review the cookie generation.

32 changes: 32 additions & 0 deletions src/views/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,13 @@ export function dashboardHtml(csrfToken: string, nonce: string): string {
<strong>302/307 (Temporary):</strong> Browsers cache for 1 hour - use for temporary redirects or A/B testing.
</small>
</div>
<div class="form-group">
<label for="link-password">Password (Optional)</label>
<input type="password" id="link-password" placeholder="Leave blank for no password">
<small style="display: block; margin-top: 0.25rem; color: #666;">
Restrict access to this link with a password.
</small>
</div>
<div class="form-group">
<label for="link-category">Category (optional)</label>
<select id="link-category">
Expand Down Expand Up @@ -2035,6 +2042,7 @@ export function dashboardHtml(csrfToken: string, nonce: string): string {
description: document.getElementById('link-description').value || undefined,
redirect_code: parseInt(document.getElementById('link-redirect-code').value),
category_id: document.getElementById('link-category').value || undefined,
password: document.getElementById('link-password').value || undefined,
};
// Only include tags if they exist
if (selectedTags && selectedTags.length > 0) {
Expand Down Expand Up @@ -2114,6 +2122,8 @@ export function dashboardHtml(csrfToken: string, nonce: string): string {
document.getElementById('link-title').value = link.data.title || '';
document.getElementById('link-description').value = link.data.description || '';
document.getElementById('link-redirect-code').value = link.data.redirect_code || 301;
document.getElementById('link-password').value = ''; // Don't show hash, just empty for "no change"
document.getElementById('link-password').placeholder = link.data.password_hash ? '******** (Leave blank to keep current)' : 'Leave blank for no password';

// Set category AFTER categories are loaded
// Set category AFTER categories are loaded
Expand Down Expand Up @@ -2180,6 +2190,28 @@ export function dashboardHtml(csrfToken: string, nonce: string): string {
redirect_code: parseInt(document.getElementById('link-redirect-code').value),
category_id: document.getElementById('link-category').value || undefined,
};

const password = document.getElementById('link-password').value;
if (password) {
formData.password = password;
} else if (password === '') {
// If empty, check if we want to remove it?
// Wait, logic is: blank = no change if exists, or no password if new?
// The placeholder says "Leave blank to keep current" if hash exists.
// So if blank, we send undefined (no change).
// But how to remove password?
// Maybe we need a checkbox or explicit action?
// For now, assume blank = no change.
// Actually, if user wants to remove password, they might clear it.
// But blank usually means "ignore".
// Let's stick to: if blank, send undefined (api ignores it).
// If user types something, send it.
// To remove password, we might need a "Clear Password" button or specific UI.
// For simplicity in this task: Only support setting/changing password.
// To clear, maybe send a special value? But API expects string.
// Let's just handle setting for now.
}
Comment on lines +2194 to +2213
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code contains extensive comments reflecting confusion about how to handle password updates, particularly removal. The current logic only handles setting a new password and doesn't provide a way for users to remove an existing one, even though the API supports it. The comments should be removed and replaced with clear logic.

To enable password removal, you could add a checkbox to the UI. A simpler approach without UI changes is to check if the password field is empty and then explicitly send password: '' to the API. The current logic if (password) prevents this.

I suggest replacing this commented block with a clear implementation.

Suggested change
const password = document.getElementById('link-password').value;
if (password) {
formData.password = password;
} else if (password === '') {
// If empty, check if we want to remove it?
// Wait, logic is: blank = no change if exists, or no password if new?
// The placeholder says "Leave blank to keep current" if hash exists.
// So if blank, we send undefined (no change).
// But how to remove password?
// Maybe we need a checkbox or explicit action?
// For now, assume blank = no change.
// Actually, if user wants to remove password, they might clear it.
// But blank usually means "ignore".
// Let's stick to: if blank, send undefined (api ignores it).
// If user types something, send it.
// To remove password, we might need a "Clear Password" button or specific UI.
// For simplicity in this task: Only support setting/changing password.
// To clear, maybe send a special value? But API expects string.
// Let's just handle setting for now.
}
const passwordInput = document.getElementById('link-password');
const password = passwordInput.value;
// Only send the password field if the user has interacted with it.
// An empty value means the user wants to remove the password.
// If the input is untouched (and empty), we send nothing to signify "no change".
if (password) {
formData.password = password;
} else if (document.activeElement === passwordInput) {
// If the field is focused and cleared, send an empty string to remove the password.
// This is a heuristic; a dedicated "Remove Password" checkbox would be more explicit.
formData.password = '';
}


// Only include tags if they exist
if (selectedTags && selectedTags.length > 0) {
formData.tags = selectedTags.map(t => typeof t === 'string' ? t : t.id);
Expand Down
107 changes: 107 additions & 0 deletions src/views/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
export const passwordHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Protected Link</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--bg-color: #f3f4f6;
--card-bg: #ffffff;
--text-color: #1f2937;
--border-color: #e5e7eb;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.card {
background: var(--card-bg);
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
width: 100%;
max-width: 400px;
text-align: center;
}
h1 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.5rem;
font-weight: 600;
}
p {
margin-bottom: 1.5rem;
color: #6b7280;
}
input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-sizing: border-box;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
button {
width: 100%;
padding: 0.75rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--primary-hover);
}
.error {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.5rem;
display: none;
}
</style>
</head>
<body>
<div class="card">
<h1>Password Protected</h1>
<p>This link is password protected. Please enter the password to continue.</p>
<form id="password-form" method="POST" action="/__verify_password__">
<input type="hidden" name="domain" value="{{DOMAIN}}">
<input type="hidden" name="slug" value="{{SLUG}}">
<input type="hidden" name="link_id" value="{{LINK_ID}}">
<input type="password" name="password" placeholder="Enter password" required autofocus>
<button type="submit">Submit</button>
<div id="error-msg" class="error">Incorrect password</div>
</form>
Comment on lines +89 to +96
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add CSRF protection to the password form.

The password verification form lacks CSRF protection, making it vulnerable to cross-site request forgery attacks. An attacker could craft a malicious page that submits passwords to /__verify_password__ on behalf of a user.

🔎 Recommended approach

Generate a CSRF token server-side when rendering the password prompt and validate it in the /__verify_password__ endpoint. The token should be:

  • Cryptographically random
  • Tied to the user's session
  • Validated before processing the password

Example approach:

-      <input type="hidden" name="link_id" value="{{LINK_ID}}">
+      <input type="hidden" name="link_id" value="{{LINK_ID}}">
+      <input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<form id="password-form" method="POST" action="/__verify_password__">
<input type="hidden" name="domain" value="{{DOMAIN}}">
<input type="hidden" name="slug" value="{{SLUG}}">
<input type="hidden" name="link_id" value="{{LINK_ID}}">
<input type="password" name="password" placeholder="Enter password" required autofocus>
<button type="submit">Submit</button>
<div id="error-msg" class="error">Incorrect password</div>
</form>
<form id="password-form" method="POST" action="/__verify_password__">
<input type="hidden" name="domain" value="{{DOMAIN}}">
<input type="hidden" name="slug" value="{{SLUG}}">
<input type="hidden" name="link_id" value="{{LINK_ID}}">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<input type="password" name="password" placeholder="Enter password" required autofocus>
<button type="submit">Submit</button>
<div id="error-msg" class="error">Incorrect password</div>
</form>
🤖 Prompt for AI Agents
In src/views/password.ts around lines 89 to 96, the password form has no CSRF
protection; generate a cryptographically-random CSRF token server-side, store it
on the user session (or other per-session store) when rendering the form, add it
as a hidden input in the form (e.g., name="csrf_token"), and in the
/__verify_password__ handler validate the submitted token against the
session-stored token (and check expiry/single-use) before processing the
password; reject requests with a missing/invalid token with a 403 and clear or
rotate the token after successful validation.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules please review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

</div>
<script>
// Check for error query param
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error')) {
document.getElementById('error-msg').style.display = 'block';
}
</script>
</body>
</html>
`;
4 changes: 2 additions & 2 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ database_id = "" # Replace with your actual database ID
# You must hardcode your actual KV namespace IDs here (get them from: wrangler kv:namespace list)
[[kv_namespaces]]
binding = "CACHE"
id = "" # Replace with your actual KV namespace ID
preview_id = "" # Replace with your actual KV preview ID
id = "fake-kv-id" # Replace with your actual KV namespace ID
preview_id = "fake-kv-preview-id" # Replace with your actual KV preview ID
Comment on lines +22 to +23
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Replace placeholder KV namespace IDs before deployment.

The KV namespace IDs are set to placeholder values ("fake-kv-id" and "fake-kv-preview-id"), which will cause deployment failures. As noted in the comments, these must be replaced with actual KV namespace IDs obtained from wrangler kv:namespace list.

Since the password protection feature relies on the CACHE binding for storing link metadata, deploying with these placeholder values will break the functionality.

🤖 Prompt for AI Agents
In wrangler.toml around lines 22 to 23, the KV namespace IDs are placeholders
("fake-kv-id" and "fake-kv-preview-id") which will break deployment and the
password protection feature that uses the CACHE binding; replace those values
with the actual namespace IDs returned by running `wrangler kv:namespace list`,
put the production ID in the id field and the preview/test ID in preview_id (or
update the appropriate environment sections if you use env-specific bindings),
and verify the KV binding name matches the binding referenced in your code
(e.g., CACHE) before redeploying.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules please review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!



# Analytics Engine
Expand Down