-
Notifications
You must be signed in to change notification settings - Fork 6
Feature: Password Protected Links #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d62fc5f
7e3ff53
f19caea
acf31ef
6d1061f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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 { | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules please review
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type assertions 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);
}
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules review
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules please review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } catch (e) { | ||
| console.error('Password verification error:', e); | ||
| return c.text('Internal Server Error', 500); | ||
| } | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // LINK REDIRECT HANDLER - Catch-all for short link redirects | ||
| // ============================================================================ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ReDoS vulnerability: Regular expression constructed from variable input. As flagged by static analysis, constructing a RegExp from 🔎 Proposed fix using string methodsReplace 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. (regexp-from-variable) 🤖 Prompt for AI Agents
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules please review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The values for You can also consider moving this const escapeHtml = (unsafe: string): string => {
if (!unsafe) return '';
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
let html = passwordHtml
.replace('{{DOMAIN}}', escapeHtml(domain.domain_name))
.replace('{{SLUG}}', escapeHtml(slug))
.replace('{{LINK_ID}}', escapeHtml(cached.link_id));
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules review
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| }); | ||
| } | ||
| } | ||
|
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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The This suggestion adds a For even stronger security, consider using HMAC (Hash-based Message Authentication Code) with
Suggested change
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules review
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major security flaw: Deterministic authentication cookie enables forgery. The
The appended constant 🔎 Recommended fix: Use HMAC with a secret keyReplace 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:
You'll need to add a
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged. I'll review the cookie generation. |
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 I suggest replacing this commented block with a clear implementation.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only include tags if they exist | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (selectedTags && selectedTags.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| formData.tags = selectedTags.map(t => typeof t === 'string' ? t : t.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 🔎 Recommended approachGenerate a CSRF token server-side when rendering the password prompt and validate it in the
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
Suggested change
🤖 Prompt for AI Agents
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules please review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||
| </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> | ||||||||||||||||||||||||||||||||||||
| `; | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace placeholder KV namespace IDs before deployment. The KV namespace IDs are set to placeholder values ( 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules please review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
||
| # Analytics Engine | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 toRESERVED_SLUGS(or move the endpoint under/api/...) so user-defined slugs cannot collide with the password verifier route.Useful? React with 👍 / 👎.