diff --git a/.github/README.md b/.github/README.md index ae6bc44..c0e630d 100644 --- a/.github/README.md +++ b/.github/README.md @@ -2,7 +2,7 @@ Emberly is an open source platform for modern file storage, sharing, and identity verification. Build your digital presence with powerful tools for teams and individuals. -![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/EmberlyOSS/Emberly?utm_source=oss&utm_medium=github&utm_campaign=EmberlyOSS%2FEmberly&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) +[![Build Checks](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/build.yml) [![CodeQL Advanced](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml/badge.svg)](https://github.com/EmberlyOSS/Emberly/actions/workflows/codeql.yml) ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/EmberlyOSS/Emberly?utm_source=oss&utm_medium=github&utm_campaign=EmberlyOSS%2FEmberly&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) ## Features diff --git a/app/api/admin/integrations/test/route.ts b/app/api/admin/integrations/test/route.ts index 6d2aaea..7b1bb0d 100644 --- a/app/api/admin/integrations/test/route.ts +++ b/app/api/admin/integrations/test/route.ts @@ -13,6 +13,55 @@ interface TestIntegrationBody { credentials: Record } +function isPrivateOrLocalHost(hostname: string): boolean { + const host = hostname.toLowerCase() + + if (host === 'localhost' || host === '::1' || host === '[::1]') return true + if (host.endsWith('.localhost') || host.endsWith('.local')) return true + + // IPv4 checks + const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4Match) { + const octets = ipv4Match.slice(1).map((n) => Number(n)) + if (octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) return true + + const [a, b] = octets + if (a === 10) return true + if (a === 127) 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 === 0) return true + } + + // Common IPv6 local/private forms + const normalized = host.replace(/^\[|\]$/g, '') + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true // ULA + if (normalized.startsWith('fe80:')) return true // link-local + + return false +} + +function getSafeKenerOrigin(baseUrl?: string): string | null { + const candidate = (baseUrl && baseUrl.trim()) || 'https://emberlystat.us' + + let parsed: URL + try { + parsed = new URL(candidate) + } catch { + return null + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null + if (parsed.username || parsed.password) return null + if (isPrivateOrLocalHost(parsed.hostname)) return null + + const allowedOrigins = new Set(['https://emberlystat.us']) + if (!allowedOrigins.has(parsed.origin)) return null + + return parsed.origin +} + interface TestResult { ok: boolean message: string @@ -86,7 +135,28 @@ async function testDiscord(webhookUrl: string, botToken?: string, serverId?: str // Fall back to webhook validation if (webhookUrl) { try { - const res = await fetch(webhookUrl, { method: 'GET' }) + let parsed: URL + try { + parsed = new URL(webhookUrl) + } catch { + return { ok: false, message: 'Invalid webhook URL format' } + } + + const hostname = parsed.hostname.toLowerCase() + const isDiscordHost = hostname === 'discord.com' || hostname === 'discordapp.com' + if (parsed.protocol !== 'https:' || !isDiscordHost || isPrivateOrLocalHost(hostname)) { + return { ok: false, message: 'Webhook URL must be a valid Discord HTTPS URL' } + } + + const match = parsed.pathname.match(/^\/api\/webhooks\/(\d+)\/([A-Za-z0-9._-]+)$/) + if (!match) { + return { ok: false, message: 'Webhook URL must match Discord webhook format' } + } + + const [, webhookId, webhookToken] = match + const safeWebhookUrl = `https://${hostname}/api/webhooks/${webhookId}/${webhookToken}` + + const res = await fetch(safeWebhookUrl, { method: 'GET' }) if (res.status === 401) return { ok: false, message: 'Invalid webhook URL' } if (!res.ok) return { ok: false, message: `Discord webhook error (${res.status})` } return { ok: true, message: 'Discord webhook is valid' } @@ -98,11 +168,23 @@ async function testDiscord(webhookUrl: string, botToken?: string, serverId?: str return { ok: false, message: 'No credentials configured' } } +function sanitizeGitHubOrg(org?: string): string | null { + if (!org) return null + const trimmed = org.trim() + // GitHub org/user name rules: alphanumeric or single hyphens, no leading/trailing hyphen, max 39 chars. + if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(trimmed)) return null + return trimmed +} + async function testGitHub(pat: string, org?: string): Promise { if (!pat) return { ok: false, message: 'Personal access token is not configured' } + const safeOrg = sanitizeGitHubOrg(org) + if (org && !safeOrg) { + return { ok: false, message: 'Invalid GitHub organization name format' } + } try { - const endpoint = org - ? `https://api.github.com/orgs/${org}` + const endpoint = safeOrg + ? `https://api.github.com/orgs/${safeOrg}` : 'https://api.github.com/user' const res = await fetch(endpoint, { headers: { @@ -112,19 +194,23 @@ async function testGitHub(pat: string, org?: string): Promise { }, }) if (res.status === 401) return { ok: false, message: 'Invalid personal access token' } - if (res.status === 404) return { ok: false, message: `Organization "${org}" not found` } + if (res.status === 404) return { ok: false, message: `Organization "${safeOrg ?? org}" not found` } if (!res.ok) return { ok: false, message: `GitHub API error (${res.status})` } const json = await res.json().catch(() => null) - return { ok: true, message: `Connected to GitHub${org ? ` — org: ${json?.name ?? org}` : ` — user: ${json?.login}`}` } + return { ok: true, message: `Connected to GitHub${safeOrg ? ` — org: ${json?.name ?? safeOrg}` : ` — user: ${json?.login}`}` } } catch (err) { return { ok: false, message: 'Failed to reach GitHub API', detail: String(err) } } } async function testKener(apiKey: string, baseUrl?: string): Promise { - const url = (baseUrl || 'https://emberlystat.us').replace(/\/$/, '') + const origin = getSafeKenerOrigin(baseUrl) + if (!origin) { + return { ok: false, message: 'Invalid Kener base URL' } + } + try { - const res = await fetch(`${url}/api/v4/monitors`, { + const res = await fetch(`${origin}/api/v4/monitors`, { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, signal: AbortSignal.timeout(8000), }) diff --git a/app/api/discovery/signals/[id]/route.ts b/app/api/discovery/signals/[id]/route.ts index 658a333..7372853 100644 --- a/app/api/discovery/signals/[id]/route.ts +++ b/app/api/discovery/signals/[id]/route.ts @@ -8,7 +8,9 @@ import { prisma } from '@/packages/lib/database/prisma' function parseGitHubUrl(url: string): { owner: string; repo: string } | null { try { const parsed = new URL(url) - if (!parsed.hostname.endsWith('github.com')) return null + const hostname = parsed.hostname.toLowerCase().replace(/\.$/, '') + const allowedHosts = new Set(['github.com', 'www.github.com']) + if (!allowedHosts.has(hostname)) return null const parts = parsed.pathname.replace(/^\//, '').split('/') if (parts.length < 2) return null return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') } diff --git a/app/api/discovery/signals/route.ts b/app/api/discovery/signals/route.ts index 457f4ae..696d04e 100644 --- a/app/api/discovery/signals/route.ts +++ b/app/api/discovery/signals/route.ts @@ -12,7 +12,8 @@ import { function parseGitHubUrl(url: string): { owner: string; repo: string } | null { try { const parsed = new URL(url) - if (!parsed.hostname.endsWith('github.com')) return null + const host = parsed.hostname.toLowerCase() + if (host !== 'github.com' && !host.endsWith('.github.com')) return null const parts = parsed.pathname.replace(/^\//, '').split('/') if (parts.length < 2) return null return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') } diff --git a/packages/lib/auth/login-detection.ts b/packages/lib/auth/login-detection.ts index b0ec3bf..cdd2538 100644 --- a/packages/lib/auth/login-detection.ts +++ b/packages/lib/auth/login-detection.ts @@ -44,7 +44,6 @@ export function createDeviceFingerprint(userAgent: string | null | undefined): s .replace(/mac os x [\d_]+/g, 'macos') .replace(/android [\d.]+/g, 'android') .replace(/iphone os [\d_]+/g, 'ios') - .replace(/linux/g, 'linux') // Extract browser .replace(/chrome\/[\d.]+/g, 'chrome') .replace(/firefox\/[\d.]+/g, 'firefox') diff --git a/packages/lib/security/backfill-password-history.ts b/packages/lib/security/backfill-password-history.ts index 1522989..c5b1171 100644 --- a/packages/lib/security/backfill-password-history.ts +++ b/packages/lib/security/backfill-password-history.ts @@ -130,7 +130,7 @@ export async function backfillUserPasswordHistory(userId: string): Promise { const integrations = await getIntegrations() @@ -28,7 +59,7 @@ async function vultrRequest( path: string, body?: unknown, ): Promise { - const response = await fetch(`${VULTR_API_BASE}${path}`, { + const response = await fetch(buildVultrApiUrl(path), { method, headers: { Authorization: `Bearer ${await getApiKey()}`, @@ -187,7 +218,19 @@ export async function listClusters(): Promise { /** List tiers available for a specific cluster. */ export async function listClusterTiers(clusterId: number): Promise { - const data = await vultrRequest<{ tiers: VultrTier[] }>('GET', `/object-storage/clusters/${clusterId}/tiers`) + if (!Number.isFinite(clusterId) || !Number.isSafeInteger(clusterId)) { + throw new Error(`Invalid clusterId: ${clusterId}`) + } + + const safeClusterId = Math.trunc(clusterId) + if (safeClusterId !== clusterId || safeClusterId < 0) { + throw new Error(`Invalid clusterId: ${clusterId}`) + } + + const data = await vultrRequest<{ tiers: VultrTier[] }>( + 'GET', + `/object-storage/clusters/${safeClusterId}/tiers` + ) return data.tiers ?? [] } diff --git a/scripts/generate-media-kit.ts b/scripts/generate-media-kit.ts index 30b9a98..9a96dc8 100644 --- a/scripts/generate-media-kit.ts +++ b/scripts/generate-media-kit.ts @@ -16,10 +16,10 @@ import { mkdir, writeFile, copyFile, readFile, readdir } from 'fs/promises' import { existsSync } from 'fs' import { join } from 'path' -import { exec } from 'child_process' +import { execFile } from 'child_process' import { promisify } from 'util' -const execAsync = promisify(exec) +const execFileAsync = promisify(execFile) const ROOT_DIR = process.cwd() const PUBLIC_DIR = join(ROOT_DIR, 'public') @@ -440,10 +440,19 @@ async function createZip() { try { if (process.platform === 'win32') { // PowerShell Compress-Archive - await execAsync(`powershell -Command "Compress-Archive -Path '${OUTPUT_DIR}\\*' -DestinationPath '${OUTPUT_ZIP}' -Force"`) + await execFileAsync('powershell', [ + '-NoProfile', + '-Command', + 'Compress-Archive', + '-Path', + `${OUTPUT_DIR}\\*`, + '-DestinationPath', + OUTPUT_ZIP, + '-Force', + ]) } else { // Unix zip - await execAsync(`cd "${OUTPUT_DIR}" && zip -r "${OUTPUT_ZIP}" .`) + await execFileAsync('zip', ['-r', OUTPUT_ZIP, '.'], { cwd: OUTPUT_DIR }) } console.log(`✓ Created zip: ${OUTPUT_ZIP}`) } catch (error) {