your personal website on atproto - mirror blento.app
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #279 from flo-bit/fix/small-fixes

small fixes, security updates

authored by

Florian and committed by
GitHub
a2eaa5e5 cb65835f

+116 -25
+38
src/lib/ssrf.ts
··· 1 + const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; 2 + 3 + function isPrivateIpv4(host: string): boolean { 4 + const m = host.match(IPV4); 5 + if (!m) return false; 6 + const [a, b] = m.slice(1).map(Number); 7 + if (a === 10 || a === 127 || a === 0) return true; 8 + if (a === 169 && b === 254) return true; 9 + if (a === 172 && b >= 16 && b <= 31) return true; 10 + if (a === 192 && b === 168) return true; 11 + if (a >= 224) return true; 12 + return false; 13 + } 14 + 15 + function isBlockedHost(host: string): boolean { 16 + const h = host.toLowerCase(); 17 + if (h === 'localhost' || h.endsWith('.localhost')) return true; 18 + if (h.endsWith('.local') || h.endsWith('.internal')) return true; 19 + if (h.includes(':')) return true; 20 + if (isPrivateIpv4(h)) return true; 21 + return false; 22 + } 23 + 24 + export function parseSafeUrl(raw: string): URL { 25 + let u: URL; 26 + try { 27 + u = new URL(raw); 28 + } catch { 29 + throw new Error('Invalid URL'); 30 + } 31 + if (u.protocol !== 'https:' && u.protocol !== 'http:') { 32 + throw new Error('Only http(s) URLs are allowed'); 33 + } 34 + if (isBlockedHost(u.hostname)) { 35 + throw new Error('Host is not allowed'); 36 + } 37 + return u; 38 + }
+1 -1
src/lib/website/Pronouns.svelte
··· 69 69 {/if} 70 70 71 71 {#if editing} 72 - <Button size="iconLg" onclick={openModal}> 72 + <Button size="sm" variant="secondary" onclick={openModal} class="w-fit"> 73 73 <svg 74 74 xmlns="http://www.w3.org/2000/svg" 75 75 fill="none"
+29 -4
src/routes/api/activate-domain/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import { isDid } from '@atcute/lexicons/syntax'; 3 3 import { getRecord } from '$lib/atproto/methods'; 4 + import { verifyDomainDns } from '$lib/dns'; 4 5 import type { Did } from '@atcute/lexicons'; 5 6 6 - export async function POST({ request, platform }) { 7 + const EXPECTED_TARGET = 'blento-proxy.fly.dev'; 8 + 9 + export async function POST({ request, platform, locals }) { 10 + if (!locals.did) { 11 + return json({ error: 'Not authenticated' }, { status: 401 }); 12 + } 13 + 7 14 let body: { did: string; domain: string }; 8 15 try { 9 16 body = await request.json(); ··· 21 28 return json({ error: 'Invalid DID format' }, { status: 400 }); 22 29 } 23 30 24 - // Validate domain format 31 + if (did !== locals.did) { 32 + return json({ error: 'DID does not match authenticated session' }, { status: 403 }); 33 + } 34 + 25 35 if ( 26 36 !/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test( 27 37 domain ··· 29 39 ) { 30 40 return json({ error: 'Invalid domain format' }, { status: 400 }); 31 41 } 42 + 43 + const normalizedDomain = domain.toLowerCase(); 32 44 33 45 // Verify the user's ATProto profile has this domain set 34 46 try { ··· 51 63 return json({ error: 'Failed to verify profile record.' }, { status: 500 }); 52 64 } 53 65 54 - // Write to CUSTOM_DOMAINS KV 66 + // Verify the domain actually points at our proxy via DNS before binding it. 67 + try { 68 + const result = await verifyDomainDns(normalizedDomain, EXPECTED_TARGET); 69 + if (!result.ok) { 70 + return json({ error: result.error, hint: result.hint }, { status: 400 }); 71 + } 72 + } catch { 73 + return json({ error: 'Failed to verify DNS records.' }, { status: 500 }); 74 + } 75 + 55 76 const kv = platform?.env?.CUSTOM_DOMAINS; 56 77 if (!kv) { 57 78 return json({ error: 'KV storage not available.' }, { status: 500 }); 58 79 } 59 80 60 81 try { 61 - await kv.put(domain.toLowerCase(), did); 82 + const existing = await kv.get(normalizedDomain); 83 + if (existing && existing !== did) { 84 + return json({ error: 'Domain is already bound to a different account.' }, { status: 409 }); 85 + } 86 + await kv.put(normalizedDomain, did); 62 87 } catch { 63 88 return json({ error: 'Failed to register domain.' }, { status: 500 }); 64 89 }
+11 -2
src/routes/api/cron/+server.ts
··· 1 + import { timingSafeEqual } from 'node:crypto'; 1 2 import { contrail, ensureInit } from '$lib/contrail'; 2 3 import type { RequestHandler } from './$types'; 3 4 5 + function safeEqual(a: string, b: string): boolean { 6 + const aBuf = new TextEncoder().encode(a); 7 + const bBuf = new TextEncoder().encode(b); 8 + if (aBuf.length !== bBuf.length) return false; 9 + return timingSafeEqual(aBuf, bBuf); 10 + } 11 + 4 12 export const POST: RequestHandler = async ({ request, platform }) => { 5 - const secret = request.headers.get('X-Cron-Secret'); 6 - if (secret !== platform!.env.CRON_SECRET) { 13 + const secret = request.headers.get('X-Cron-Secret') ?? ''; 14 + const expected = platform!.env.CRON_SECRET ?? ''; 15 + if (!expected || !safeEqual(secret, expected)) { 7 16 return new Response('Unauthorized', { status: 401 }); 8 17 } 9 18
+5 -1
src/routes/api/geocoding/+server.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 2 import { json } from '@sveltejs/kit'; 3 3 4 - export async function GET({ url }) { 4 + export async function GET({ url, locals }) { 5 + if (!locals.did) { 6 + return json({ error: 'Not authenticated' }, { status: 401 }); 7 + } 8 + 5 9 const q = url.searchParams.get('q'); 6 10 if (!q) { 7 11 return json({ error: 'No search provided' }, { status: 400 });
+11 -6
src/routes/api/image-proxy/+server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 + import { parseSafeUrl } from '$lib/ssrf'; 2 3 3 - export async function GET({ url }) { 4 + export async function GET({ url, locals }) { 5 + if (!locals.did) { 6 + throw error(401, 'Not authenticated'); 7 + } 8 + 4 9 const imageUrl = url.searchParams.get('url'); 5 10 if (!imageUrl) { 6 11 throw error(400, 'No URL provided'); 7 12 } 8 13 14 + let target: URL; 9 15 try { 10 - new URL(imageUrl); 11 - } catch { 12 - throw error(400, 'Invalid URL'); 16 + target = parseSafeUrl(imageUrl); 17 + } catch (e) { 18 + throw error(400, e instanceof Error ? e.message : 'Invalid URL'); 13 19 } 14 20 15 21 try { 16 - const response = await fetch(imageUrl); 22 + const response = await fetch(target, { redirect: 'follow' }); 17 23 18 24 if (!response.ok) { 19 25 throw error(response.status, 'Failed to fetch image'); ··· 21 27 22 28 const contentType = response.headers.get('content-type'); 23 29 24 - // Only allow image content types 25 30 if (!contentType?.startsWith('image/')) { 26 31 throw error(400, 'URL does not point to an image'); 27 32 }
+5 -1
src/routes/api/instagram/info/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 3 - export async function GET({ url }) { 3 + export async function GET({ url, locals }) { 4 + if (!locals.did) { 5 + return json({ error: 'Not authenticated' }, { status: 401 }); 6 + } 7 + 4 8 const username = url.searchParams.get('username'); 5 9 if (!username) { 6 10 return json({ error: 'No username provided' }, { status: 400 });
+16 -10
src/routes/api/links/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import { getLinkPreview } from 'link-preview-js'; 3 + import { parseSafeUrl } from '$lib/ssrf'; 3 4 4 - export async function GET({ url }) { 5 + export async function GET({ url, locals }) { 6 + if (!locals.did) { 7 + return json({ error: 'Not authenticated' }, { status: 401 }); 8 + } 9 + 5 10 const link = url.searchParams.get('link'); 6 11 if (!link) { 7 12 return json({ error: 'No link provided' }, { status: 400 }); 8 13 } 9 14 10 15 try { 11 - new URL(link); 12 - } catch { 13 - return json({ error: 'Link is not a valid url' }, { status: 400 }); 16 + parseSafeUrl(link); 17 + } catch (e) { 18 + return json({ error: e instanceof Error ? e.message : 'Invalid URL' }, { status: 400 }); 14 19 } 15 20 16 21 try { 17 22 const data = await getLinkPreview(link, { 18 23 followRedirects: `manual`, 19 24 handleRedirects: (baseURL: string, forwardedURL: string) => { 25 + try { 26 + parseSafeUrl(forwardedURL); 27 + } catch { 28 + return false; 29 + } 20 30 const urlObj = new URL(baseURL); 21 31 const forwardedURLObj = new URL(forwardedURL); 22 - if ( 32 + return ( 23 33 forwardedURLObj.hostname === urlObj.hostname || 24 34 forwardedURLObj.hostname === 'www.' + urlObj.hostname || 25 35 'www.' + forwardedURLObj.hostname === urlObj.hostname 26 - ) { 27 - return true; 28 - } else { 29 - return false; 30 - } 36 + ); 31 37 } 32 38 }); 33 39 return json(data);