From d62fc5fa0c71755f60180cdc7fa8f4d61cec4240 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:26:28 +0000 Subject: [PATCH 1/5] Implement password protection for short links --- package.json | 2 +- src/api/links.ts | 18 +++++++ src/index.ts | 67 ++++++++++++++++++++++++ src/schemas/link.ts | 1 + src/services/redirect.ts | 25 +++++++++ src/views/dashboard.ts | 32 ++++++++++++ src/views/password.ts | 107 +++++++++++++++++++++++++++++++++++++++ wrangler.toml | 4 +- 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 src/views/password.ts diff --git a/package.json b/package.json index 81ed09a..e9c2fbb 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/api/links.ts b/src/api/links.ts index e26df36..aa2e67a 100644 --- a/src/api/links.ts +++ b/src/api/links.ts @@ -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 }>(); @@ -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, @@ -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, @@ -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); diff --git a/src/index.ts b/src/index.ts index 6497057..a50b507 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { securityHeaders } from './middleware/security'; import { cacheControl } from './middleware/cache-control'; import { handleRedirect } from './services/redirect'; import { getDomainByRoutingPath } from './db/domains'; +import { getLinkById } from './db/links'; +import { verifyPassword } from './utils/crypto'; // Import API routes (static - they're small and needed for functionality) import { linksRouter } from './api/links'; @@ -273,6 +275,71 @@ 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; + const domain = body['domain'] as string; + const slug = body['slug'] as string; + + if (!linkId || !password || !domain || !slug) { + return c.text('Missing required fields', 400); + } + + const link = await getLinkById(c.env, linkId); + if (!link) { + return c.text('Link not found', 404); + } + + if (!link.password_hash) { + // Link is not password protected, just redirect + const url = \`https://\${domain}/\${slug}\`; + return c.redirect(url); + } + + 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}\`; + + // We can't use c.cookie() easily because we are redirecting to a potentially different domain? + // Wait, the POST request is to the current domain (where the password page was served). + // The password page is served from `short.link/foo`. + // The form action is `/__verify_password__`. + // Since `handleRedirect` handles `*`, it would also catch `POST /__verify_password__` if we didn't define it here. + // But we defined it here BEFORE `app.get('*', ...)`? + // `app.get('*')` is at the end. + // Wait, `app` uses Hono. + // If we visit `short.link/foo`, and it returns HTML. + // The form action is `/__verify_password__`. + // The browser sends POST to `short.link/__verify_password__`. + // Cloudflare Worker receives request for `short.link/__verify_password__`. + // We need to ensure this route matches. + + // Set cookie on the domain + 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\`; + return c.redirect(url); + } + } catch (e) { + console.error('Password verification error:', e); + return c.text('Internal Server Error', 500); + } +}); + // ============================================================================ // LINK REDIRECT HANDLER - Catch-all for short link redirects // ============================================================================ diff --git a/src/schemas/link.ts b/src/schemas/link.ts index 883cad1..9381808 100644 --- a/src/schemas/link.ts +++ b/src/schemas/link.ts @@ -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 }); // ============================================================================ diff --git a/src/services/redirect.ts b/src/services/redirect.ts index 9161a22..983934a 100644 --- a/src/services/redirect.ts +++ b/src/services/redirect.ts @@ -5,6 +5,7 @@ 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'; /** * Merges query parameters from the request URL into the destination URL. @@ -168,6 +169,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]; + + // Simple check: if cookie is "valid" (in real world, sign this) + if (authCookie !== 'valid') { + // 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); + + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-store', // Don't cache the prompt + } + }); + } + } + // Strict Routing Check (performed AFTER cache retrieval to ensure it applies to cached links too) if (matchedRoute) { const linkRoute = cached.route; diff --git a/src/views/dashboard.ts b/src/views/dashboard.ts index 5a905a5..fde0ddc 100644 --- a/src/views/dashboard.ts +++ b/src/views/dashboard.ts @@ -848,6 +848,13 @@ export function dashboardHtml(csrfToken: string, nonce: string): string { 302/307 (Temporary): Browsers cache for 1 hour - use for temporary redirects or A/B testing. +
+ + + + Restrict access to this link with a password. + +
+ + + + +
Incorrect password
+ +
+ + + +`; diff --git a/wrangler.toml b/wrangler.toml index 147781f..2767434 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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 # Analytics Engine From 7e3ff535e0804d5479a5a45ccbf73d3dd84af231 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:39:22 +0000 Subject: [PATCH 2/5] Implement password protection for short links From f19caeaf401b71bc311b9afa137a63fc45170604 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:44:36 +0000 Subject: [PATCH 3/5] Implement password protection for short links From acf31efe59c0e5020766c548be1eb47e6bcf3e0f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:53:07 +0000 Subject: [PATCH 4/5] Security hardening for password protection feature --- src/index.ts | 33 ++++++++++++++------------------- src/services/redirect.ts | 5 +++-- src/utils/crypto.ts | 8 ++++++++ 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index a50b507..ddfaaa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +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 } from './utils/crypto'; +import { verifyPassword, generateAuthCookie } from './utils/crypto'; // Import API routes (static - they're small and needed for functionality) import { linksRouter } from './api/links'; @@ -284,10 +284,11 @@ app.post('/__verify_password__', async (c) => { const body = await c.req.parseBody(); const linkId = body['link_id'] as string; const password = body['password'] as string; - const domain = body['domain'] as string; + // We ignore the domain from body to prevent open redirects + // const domain = body['domain'] as string; const slug = body['slug'] as string; - if (!linkId || !password || !domain || !slug) { + if (!linkId || !password || !slug) { return c.text('Missing required fields', 400); } @@ -296,6 +297,13 @@ app.post('/__verify_password__', async (c) => { 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}\`; @@ -310,23 +318,10 @@ app.post('/__verify_password__', async (c) => { const maxAge = 3600; const url = \`https://\${domain}/\${slug}\`; - - // We can't use c.cookie() easily because we are redirecting to a potentially different domain? - // Wait, the POST request is to the current domain (where the password page was served). - // The password page is served from `short.link/foo`. - // The form action is `/__verify_password__`. - // Since `handleRedirect` handles `*`, it would also catch `POST /__verify_password__` if we didn't define it here. - // But we defined it here BEFORE `app.get('*', ...)`? - // `app.get('*')` is at the end. - // Wait, `app` uses Hono. - // If we visit `short.link/foo`, and it returns HTML. - // The form action is `/__verify_password__`. - // The browser sends POST to `short.link/__verify_password__`. - // Cloudflare Worker receives request for `short.link/__verify_password__`. - // We need to ensure this route matches. + const authCookieValue = await generateAuthCookie(link.password_hash); // Set cookie on the domain - c.header('Set-Cookie', \`link_access_\${linkId}=valid; Path=/; Max-Age=\${maxAge}; Secure; HttpOnly; SameSite=Lax\`); + c.header('Set-Cookie', \`link_access_\${linkId}=\${authCookieValue}; Path=/; Max-Age=\${maxAge}; Secure; HttpOnly; SameSite=Lax\`); return c.redirect(url); } else { diff --git a/src/services/redirect.ts b/src/services/redirect.ts index 983934a..ffe95f0 100644 --- a/src/services/redirect.ts +++ b/src/services/redirect.ts @@ -6,6 +6,7 @@ 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. @@ -173,9 +174,9 @@ export async function handleRedirect( if (cached.password_hash) { const cookieHeader = request.headers.get('Cookie'); const authCookie = cookieHeader?.match(new RegExp(`link_access_${cached.link_id}=([^;]+)`))?.[1]; + const expectedCookie = await generateAuthCookie(cached.password_hash); - // Simple check: if cookie is "valid" (in real world, sign this) - if (authCookie !== 'valid') { + if (authCookie !== expectedCookie) { // Return password prompt // We need to replace placeholders let html = passwordHtml diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 7747066..f8a4a77 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -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 { + 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(''); +} From 6d1061f8ee06786a8c4996f96f5d522063cbb515 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:00:54 +0000 Subject: [PATCH 5/5] Security hardening for password protection feature