Skip to content
Merged

Sync #103

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3328d8b
Updated workflows and policies
CodeMeAPixel Jun 12, 2026
82ed172
Potential fix for code scanning alert no. 13: Server-side request for…
CodeMeAPixel Jun 12, 2026
4c6f319
Potential fix for code scanning alert no. 12: Server-side request for…
CodeMeAPixel Jun 12, 2026
a2da666
Potential fix for code scanning alert no. 18: Server-side request for…
CodeMeAPixel Jun 12, 2026
12502ad
Potential fix for code scanning alert no. 11: Server-side request for…
CodeMeAPixel Jun 12, 2026
cb2dbf5
Potential fix for code scanning alert no. 17: Server-side request for…
CodeMeAPixel Jun 12, 2026
fbb803a
Potential fix for code scanning alert no. 17: Server-side request for…
CodeMeAPixel Jun 12, 2026
53362ef
Potential fix for code scanning alert no. 15: Use of externally-contr…
CodeMeAPixel Jun 12, 2026
48d2557
Potential fix for code scanning alert no. 16: Shell command built fro…
CodeMeAPixel Jun 12, 2026
6e77cfe
Potential fix for code scanning alert no. 5: Replacement of a substri…
CodeMeAPixel Jun 12, 2026
01c2820
Potential fix for code scanning alert no. 6: Incomplete URL substring…
CodeMeAPixel Jun 12, 2026
95b2c62
Potential fix for code scanning alert no. 7: Incomplete URL substring…
CodeMeAPixel Jun 12, 2026
253749d
Potential fix for code scanning alert no. 10: Server-side request for…
CodeMeAPixel Jun 12, 2026
fd72c9f
Update README.md
CodeMeAPixel Jun 12, 2026
01e0c45
Update README.md
CodeMeAPixel Jun 12, 2026
1df329e
Potential fix for code scanning alert no. 19: Server-side request for…
CodeMeAPixel Jun 12, 2026
e49e429
Add license scan report and status
fossabot Jun 12, 2026
1bddffe
Update README.md
CodeMeAPixel Jun 12, 2026
cdf9d71
Potential fix for pull request finding 'CodeQL / Server-side request …
CodeMeAPixel Jun 12, 2026
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
3 changes: 2 additions & 1 deletion .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -176,6 +176,7 @@ Get help and connect with the community:

This project is licensed under the GNU Affero General Public License v3 (AGPL-3.0). See the [LICENSE](LICENSE) file for details.


## Code of Conduct

This project adheres to the Contributor Covenant Code of Conduct. By participating, you agree to uphold this code. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full text.
Expand Down
100 changes: 93 additions & 7 deletions app/api/admin/integrations/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,55 @@ interface TestIntegrationBody {
credentials: Record<string, string>
}

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
Expand Down Expand Up @@ -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://discord.com/api/webhooks/${encodeURIComponent(webhookId)}/${encodeURIComponent(webhookToken)}`

const res = await fetch(safeWebhookUrl, { method: 'GET' })
Comment thread
Copilot marked this conversation as resolved.
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' }
Expand All @@ -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<TestResult> {
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: {
Expand All @@ -112,19 +194,23 @@ async function testGitHub(pat: string, org?: string): Promise<TestResult> {
},
})
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<TestResult> {
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),
})
Expand Down
4 changes: 3 additions & 1 deletion app/api/discovery/signals/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/, '') }
Expand Down
3 changes: 2 additions & 1 deletion app/api/discovery/signals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/, '') }
Expand Down
1 change: 0 additions & 1 deletion packages/lib/auth/login-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/security/backfill-password-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export async function backfillUserPasswordHistory(userId: string): Promise<boole

return true
} catch (error) {
console.error(`Failed to backfill password history for user ${userId}:`, error)
console.error('Failed to backfill password history for user %s:', userId, error)
throw error
}
}
Expand Down
47 changes: 45 additions & 2 deletions packages/lib/vultr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,37 @@
import { getIntegrations } from '@/packages/lib/config'

const VULTR_API_BASE = 'https://api.vultr.com/v2'
const VULTR_API_BASE_URL = new URL(VULTR_API_BASE)

function buildVultrApiUrl(path: string): string {
if (!path.startsWith('/')) {
throw new Error(`Invalid Vultr API path: "${path}"`)
}
if (path.startsWith('//') || path.includes('..') || path.includes('://')) {
throw new Error(`Unsafe Vultr API path: "${path}"`)
}

const url = new URL(path, VULTR_API_BASE_URL)

if (url.origin !== VULTR_API_BASE_URL.origin) {
throw new Error(`Unsafe Vultr API URL origin: "${url.origin}"`)
}

if (!url.pathname.startsWith('/v2/')) {
throw new Error(`Unsafe Vultr API URL path: "${url.pathname}"`)
}

const lowerPath = url.pathname.toLowerCase()
if (
lowerPath.includes('%2e') ||
lowerPath.includes('%2f') ||
lowerPath.includes('%5c')
) {
throw new Error(`Unsafe encoded Vultr API path: "${url.pathname}"`)
}

return url.toString()
}

async function getApiKey(): Promise<string> {
const integrations = await getIntegrations()
Expand All @@ -28,7 +59,7 @@ async function vultrRequest<T>(
path: string,
body?: unknown,
): Promise<T> {
const response = await fetch(`${VULTR_API_BASE}${path}`, {
const response = await fetch(buildVultrApiUrl(path), {
method,
headers: {
Authorization: `Bearer ${await getApiKey()}`,
Expand Down Expand Up @@ -187,7 +218,19 @@ export async function listClusters(): Promise<VultrCluster[]> {

/** List tiers available for a specific cluster. */
export async function listClusterTiers(clusterId: number): Promise<VultrTier[]> {
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 ?? []
}

Expand Down
17 changes: 13 additions & 4 deletions scripts/generate-media-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
Expand Down
Loading