Skip to content
Merged
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
38 changes: 38 additions & 0 deletions src/lib/ssrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;

function isPrivateIpv4(host: string): boolean {
const m = host.match(IPV4);
if (!m) return false;
const [a, b] = m.slice(1).map(Number);
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a >= 224) return true;
return false;
}

function isBlockedHost(host: string): boolean {
const h = host.toLowerCase();
if (h === 'localhost' || h.endsWith('.localhost')) return true;
if (h.endsWith('.local') || h.endsWith('.internal')) return true;
if (h.includes(':')) return true;
if (isPrivateIpv4(h)) return true;
return false;
}

export function parseSafeUrl(raw: string): URL {
let u: URL;
try {
u = new URL(raw);
} catch {
throw new Error('Invalid URL');
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error('Only http(s) URLs are allowed');
}
if (isBlockedHost(u.hostname)) {
throw new Error('Host is not allowed');
}
return u;
}
2 changes: 1 addition & 1 deletion src/lib/website/Pronouns.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
{/if}

{#if editing}
<Button size="iconLg" onclick={openModal}>
<Button size="sm" variant="secondary" onclick={openModal} class="w-fit">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
Expand Down
33 changes: 29 additions & 4 deletions src/routes/api/activate-domain/+server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { json } from '@sveltejs/kit';
import { isDid } from '@atcute/lexicons/syntax';
import { getRecord } from '$lib/atproto/methods';
import { verifyDomainDns } from '$lib/dns';
import type { Did } from '@atcute/lexicons';

export async function POST({ request, platform }) {
const EXPECTED_TARGET = 'blento-proxy.fly.dev';

export async function POST({ request, platform, locals }) {
if (!locals.did) {
return json({ error: 'Not authenticated' }, { status: 401 });
}

let body: { did: string; domain: string };
try {
body = await request.json();
Expand All @@ -21,7 +28,10 @@ export async function POST({ request, platform }) {
return json({ error: 'Invalid DID format' }, { status: 400 });
}

// Validate domain format
if (did !== locals.did) {
return json({ error: 'DID does not match authenticated session' }, { status: 403 });
}

if (
!/^[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(
domain
Expand All @@ -30,6 +40,8 @@ export async function POST({ request, platform }) {
return json({ error: 'Invalid domain format' }, { status: 400 });
}

const normalizedDomain = domain.toLowerCase();

// Verify the user's ATProto profile has this domain set
try {
const record = await getRecord({
Expand All @@ -51,14 +63,27 @@ export async function POST({ request, platform }) {
return json({ error: 'Failed to verify profile record.' }, { status: 500 });
}

// Write to CUSTOM_DOMAINS KV
// Verify the domain actually points at our proxy via DNS before binding it.
try {
const result = await verifyDomainDns(normalizedDomain, EXPECTED_TARGET);
if (!result.ok) {
return json({ error: result.error, hint: result.hint }, { status: 400 });
}
} catch {
return json({ error: 'Failed to verify DNS records.' }, { status: 500 });
}

const kv = platform?.env?.CUSTOM_DOMAINS;
if (!kv) {
return json({ error: 'KV storage not available.' }, { status: 500 });
}

try {
await kv.put(domain.toLowerCase(), did);
const existing = await kv.get(normalizedDomain);
if (existing && existing !== did) {
return json({ error: 'Domain is already bound to a different account.' }, { status: 409 });
}
await kv.put(normalizedDomain, did);
} catch {
return json({ error: 'Failed to register domain.' }, { status: 500 });
}
Expand Down
13 changes: 11 additions & 2 deletions src/routes/api/cron/+server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { timingSafeEqual } from 'node:crypto';
import { contrail, ensureInit } from '$lib/contrail';
import type { RequestHandler } from './$types';

function safeEqual(a: string, b: string): boolean {
const aBuf = new TextEncoder().encode(a);
const bBuf = new TextEncoder().encode(b);
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}

export const POST: RequestHandler = async ({ request, platform }) => {
const secret = request.headers.get('X-Cron-Secret');
if (secret !== platform!.env.CRON_SECRET) {
const secret = request.headers.get('X-Cron-Secret') ?? '';
const expected = platform!.env.CRON_SECRET ?? '';
if (!expected || !safeEqual(secret, expected)) {
return new Response('Unauthorized', { status: 401 });
}

Expand Down
6 changes: 5 additions & 1 deletion src/routes/api/geocoding/+server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { env } from '$env/dynamic/private';
import { json } from '@sveltejs/kit';

export async function GET({ url }) {
export async function GET({ url, locals }) {
if (!locals.did) {
return json({ error: 'Not authenticated' }, { status: 401 });
}

const q = url.searchParams.get('q');
if (!q) {
return json({ error: 'No search provided' }, { status: 400 });
Expand Down
17 changes: 11 additions & 6 deletions src/routes/api/image-proxy/+server.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import { error } from '@sveltejs/kit';
import { parseSafeUrl } from '$lib/ssrf';

export async function GET({ url, locals }) {
if (!locals.did) {
throw error(401, 'Not authenticated');
}

export async function GET({ url }) {
const imageUrl = url.searchParams.get('url');
if (!imageUrl) {
throw error(400, 'No URL provided');
}

let target: URL;
try {
new URL(imageUrl);
} catch {
throw error(400, 'Invalid URL');
target = parseSafeUrl(imageUrl);
} catch (e) {
throw error(400, e instanceof Error ? e.message : 'Invalid URL');
}

try {
const response = await fetch(imageUrl);
const response = await fetch(target, { redirect: 'follow' });

if (!response.ok) {
throw error(response.status, 'Failed to fetch image');
}

const contentType = response.headers.get('content-type');

// Only allow image content types
if (!contentType?.startsWith('image/')) {
throw error(400, 'URL does not point to an image');
}
Expand Down
6 changes: 5 additions & 1 deletion src/routes/api/instagram/info/+server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { json } from '@sveltejs/kit';

export async function GET({ url }) {
export async function GET({ url, locals }) {
if (!locals.did) {
return json({ error: 'Not authenticated' }, { status: 401 });
}

const username = url.searchParams.get('username');
if (!username) {
return json({ error: 'No username provided' }, { status: 400 });
Expand Down
26 changes: 16 additions & 10 deletions src/routes/api/links/+server.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
import { json } from '@sveltejs/kit';
import { getLinkPreview } from 'link-preview-js';
import { parseSafeUrl } from '$lib/ssrf';

export async function GET({ url, locals }) {
if (!locals.did) {
return json({ error: 'Not authenticated' }, { status: 401 });
}

export async function GET({ url }) {
const link = url.searchParams.get('link');
if (!link) {
return json({ error: 'No link provided' }, { status: 400 });
}

try {
new URL(link);
} catch {
return json({ error: 'Link is not a valid url' }, { status: 400 });
parseSafeUrl(link);
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'Invalid URL' }, { status: 400 });
}

try {
const data = await getLinkPreview(link, {
followRedirects: `manual`,
handleRedirects: (baseURL: string, forwardedURL: string) => {
try {
parseSafeUrl(forwardedURL);
} catch {
return false;
}
const urlObj = new URL(baseURL);
const forwardedURLObj = new URL(forwardedURL);
if (
return (
forwardedURLObj.hostname === urlObj.hostname ||
forwardedURLObj.hostname === 'www.' + urlObj.hostname ||
'www.' + forwardedURLObj.hostname === urlObj.hostname
) {
return true;
} else {
return false;
}
);
}
});
return json(data);
Expand Down
Loading