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..ddfaaa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; + + if (!linkId || !password || !slug) { + return c.text('Missing required fields', 400); + } + + 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); + } + + 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); + } + } 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..ffe95f0 100644 --- a/src/services/redirect.ts +++ b/src/services/redirect.ts @@ -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]; + 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); + + 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/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(''); +} 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