diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts index c869acfc..a801d2db 100644 --- a/app/api/instances/sync/route.ts +++ b/app/api/instances/sync/route.ts @@ -3,6 +3,11 @@ import { timingSafeEqual } from "crypto"; import { applyCaddyConfig } from "@/src/lib/caddy"; import { applySyncPayload, getInstanceMode, getSlaveMasterToken, setSlaveLastSync, SyncPayload } from "@/src/lib/instance-sync"; +const MAX_SYNC_BODY_BYTES = Number(process.env.INSTANCE_SYNC_MAX_BYTES ?? 10 * 1024 * 1024); +const SYNC_RATE_MAX = Number(process.env.INSTANCE_SYNC_RATE_MAX ?? 60); +const SYNC_RATE_WINDOW_MS = Number(process.env.INSTANCE_SYNC_RATE_WINDOW_MS ?? 60_000); +const SYNC_RATE_LIMITS = new Map(); + /** * Timing-safe token comparison to prevent timing attacks */ @@ -16,6 +21,158 @@ function secureTokenCompare(a: string, b: string): boolean { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +function getClientIp(request: NextRequest): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0]?.trim() || "unknown"; + } + const real = request.headers.get("x-real-ip"); + if (real) { + return real.trim(); + } + return "unknown"; +} + +function checkSyncRateLimit(key: string): { blocked: boolean; retryAfterMs?: number } { + const now = Date.now(); + const entry = SYNC_RATE_LIMITS.get(key); + + if (!entry || entry.windowStart + SYNC_RATE_WINDOW_MS <= now) { + SYNC_RATE_LIMITS.set(key, { count: 1, windowStart: now }); + return { blocked: false }; + } + + if (entry.count >= SYNC_RATE_MAX) { + return { blocked: true, retryAfterMs: entry.windowStart + SYNC_RATE_WINDOW_MS - now }; + } + + entry.count += 1; + return { blocked: false }; +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function validateArray(value: unknown, validator: (item: unknown) => item is T): value is T[] { + return Array.isArray(value) && value.every(validator); +} + +function isCertificate(value: unknown): value is SyncPayload["data"]["certificates"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isString(value.type) && + isString(value.domainNames) && + isBoolean(value.autoRenew) && + isNullableString(value.providerOptions) && + isNullableString(value.certificatePem) && + isNullableString(value.privateKeyPem) && + isNullableNumber(value.createdBy) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + +function isAccessList(value: unknown): value is SyncPayload["data"]["accessLists"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isNullableString(value.description) && + isNullableNumber(value.createdBy) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + +function isAccessListEntry(value: unknown): value is SyncPayload["data"]["accessListEntries"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isNumber(value.accessListId) && + isString(value.username) && + isString(value.passwordHash) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + +function isProxyHost(value: unknown): value is SyncPayload["data"]["proxyHosts"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isString(value.domains) && + isString(value.upstreams) && + isNullableNumber(value.certificateId) && + isNullableNumber(value.accessListId) && + isNullableNumber(value.ownerUserId) && + isBoolean(value.sslForced) && + isBoolean(value.hstsEnabled) && + isBoolean(value.hstsSubdomains) && + isBoolean(value.allowWebsocket) && + isBoolean(value.preserveHostHeader) && + isNullableString(value.meta) && + isBoolean(value.enabled) && + isString(value.createdAt) && + isString(value.updatedAt) && + isBoolean(value.skipHttpsHostnameValidation) + ); +} + +function isRedirectHost(value: unknown): value is SyncPayload["data"]["redirectHosts"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isString(value.domains) && + isString(value.destination) && + isNumber(value.statusCode) && + isBoolean(value.preserveQuery) && + isBoolean(value.enabled) && + isNullableNumber(value.createdBy) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + +function isDeadHost(value: unknown): value is SyncPayload["data"]["deadHosts"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isString(value.domains) && + isNumber(value.statusCode) && + isNullableString(value.responseBody) && + isBoolean(value.enabled) && + isNullableNumber(value.createdBy) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + /** * Validates that the payload has the expected structure for syncing */ @@ -27,7 +184,11 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload { const p = payload as Record; // Check required top-level properties - if (!("settings" in p) || !("data" in p)) { + if (!("generated_at" in p) || !("settings" in p) || !("data" in p)) { + return false; + } + + if (!isString(p.generated_at)) { return false; } @@ -43,15 +204,15 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload { } const d = data as Record; - const requiredArrays = ["certificates", "accessLists", "accessListEntries", "proxyHosts", "redirectHosts", "deadHosts"]; - for (const key of requiredArrays) { - if (!(key in d) || !Array.isArray(d[key])) { - return false; - } - } - - return true; + return ( + validateArray(d.certificates, isCertificate) && + validateArray(d.accessLists, isAccessList) && + validateArray(d.accessListEntries, isAccessListEntry) && + validateArray(d.proxyHosts, isProxyHost) && + validateArray(d.redirectHosts, isRedirectHost) && + validateArray(d.deadHosts, isDeadHost) + ); } export async function POST(request: NextRequest) { @@ -60,6 +221,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Instance is not configured as a slave" }, { status: 403 }); } + const rateLimit = checkSyncRateLimit(getClientIp(request)); + if (rateLimit.blocked) { + const retryAfterSeconds = rateLimit.retryAfterMs ? Math.ceil(rateLimit.retryAfterMs / 1000) : 60; + return NextResponse.json( + { error: "Too many sync requests. Please retry later." }, + { status: 429, headers: { "Retry-After": retryAfterSeconds.toString() } } + ); + } + const authHeader = request.headers.get("authorization") ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; const expected = await getSlaveMasterToken(); @@ -70,7 +240,15 @@ export async function POST(request: NextRequest) { let payload: unknown; try { - payload = await request.json(); + const contentLength = request.headers.get("content-length"); + if (contentLength && Number.parseInt(contentLength, 10) > MAX_SYNC_BODY_BYTES) { + return NextResponse.json({ error: "Sync payload too large" }, { status: 413 }); + } + const bodyText = await request.text(); + if (bodyText.length > MAX_SYNC_BODY_BYTES) { + return NextResponse.json({ error: "Sync payload too large" }, { status: 413 }); + } + payload = JSON.parse(bodyText); } catch (error) { return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); } diff --git a/app/api/user/update-avatar/route.ts b/app/api/user/update-avatar/route.ts index 9f3dc80e..d2d4e9e0 100644 --- a/app/api/user/update-avatar/route.ts +++ b/app/api/user/update-avatar/route.ts @@ -22,11 +22,12 @@ export async function POST(request: NextRequest) { ); } - // If avatarUrl is provided, validate it's a base64 image + // If avatarUrl is provided, validate it's a base64 image (png/jpeg/webp only) if (avatarUrl !== null) { - if (!avatarUrl.startsWith("data:image/")) { + const match = avatarUrl.match(/^data:(image\/(png|jpeg|jpg|webp));base64,/i); + if (!match) { return NextResponse.json( - { error: "Avatar must be a base64-encoded image" }, + { error: "Avatar must be a base64-encoded PNG, JPEG, or WebP image" }, { status: 400 } ); } diff --git a/src/lib/instance-sync.ts b/src/lib/instance-sync.ts index 386d3f75..a6fa72e0 100644 --- a/src/lib/instance-sync.ts +++ b/src/lib/instance-sync.ts @@ -1,7 +1,8 @@ import db, { nowIso } from "./db"; import { accessListEntries, accessLists, certificates, deadHosts, proxyHosts, redirectHosts } from "./db/schema"; import { getSetting, setSetting } from "./settings"; -import { recordInstanceSyncResult } from "./models/instances"; +import { recordInstanceSyncResult, updateInstance } from "./models/instances"; +import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret"; export type InstanceMode = "standalone" | "master" | "slave"; @@ -169,7 +170,24 @@ export async function getSlaveMasterToken(): Promise { } // Fall back to database setting - return await getSetting(MASTER_TOKEN_KEY); + const stored = await getSetting(MASTER_TOKEN_KEY); + if (!stored) { + return null; + } + if (!isEncryptedSecret(stored)) { + try { + await setSetting(MASTER_TOKEN_KEY, encryptSecret(stored)); + } catch (error) { + console.warn("Failed to encrypt stored master token:", error); + } + return stored; + } + try { + return decryptSecret(stored); + } catch (error) { + console.error("Failed to decrypt stored master token:", error); + return null; + } } export async function setSlaveMasterToken(token: string | null): Promise { @@ -178,7 +196,8 @@ export async function setSlaveMasterToken(token: string | null): Promise { console.warn("Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime"); return; } - await setSetting(MASTER_TOKEN_KEY, token ?? ""); + const next = token ? encryptSecret(token) : ""; + await setSetting(MASTER_TOKEN_KEY, next); } export async function getSlaveLastSync(): Promise<{ at: string | null; error: string | null }> { @@ -293,6 +312,23 @@ export async function syncInstances(): Promise<{ total: number; success: number; // Sync database-configured instances const dbResults = await Promise.all( dbTargets.map(async (instance) => { + if (!isEncryptedSecret(instance.apiToken)) { + try { + await updateInstance(instance.id, { apiToken: instance.apiToken }); + } catch (error) { + console.warn(`Failed to encrypt stored token for instance "${instance.name}":`, error); + } + } + + let token: string; + try { + token = decryptSecret(instance.apiToken); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await recordInstanceSyncResult(instance.id, { ok: false, error: `Token decrypt failed: ${message}` }); + return { ok: false, skippedHttp: false }; + } + // Check for HTTP URL if (isHttpUrl(instance.baseUrl) && !httpAllowed) { const message = "HTTP sync blocked. Set INSTANCE_SYNC_ALLOW_HTTP=true to allow insecure sync."; @@ -306,7 +342,7 @@ export async function syncInstances(): Promise<{ total: number; success: number; method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${instance.apiToken}` + Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); diff --git a/src/lib/models/instances.ts b/src/lib/models/instances.ts index 141ff3c2..547fd7e6 100644 --- a/src/lib/models/instances.ts +++ b/src/lib/models/instances.ts @@ -1,6 +1,7 @@ import db, { nowIso, toIso } from "../db"; import { instances } from "../db/schema"; import { asc, eq } from "drizzle-orm"; +import { encryptSecret } from "../secret"; export type Instance = { id: number; @@ -57,7 +58,7 @@ export async function createInstance(input: InstanceInput): Promise { .values({ name: input.name.trim(), baseUrl: input.baseUrl.trim(), - apiToken: input.apiToken.trim(), + apiToken: encryptSecret(input.apiToken.trim()), enabled: input.enabled ?? true, createdAt: now, updatedAt: now @@ -86,7 +87,7 @@ export async function updateInstance( .set({ name: input.name?.trim() ?? existing.name, baseUrl: input.baseUrl?.trim() ?? existing.baseUrl, - apiToken: input.apiToken?.trim() ?? existing.apiToken, + apiToken: input.apiToken !== undefined ? encryptSecret(input.apiToken.trim()) : existing.apiToken, enabled: input.enabled ?? existing.enabled, updatedAt: now }) diff --git a/src/lib/secret.ts b/src/lib/secret.ts new file mode 100644 index 00000000..d10be458 --- /dev/null +++ b/src/lib/secret.ts @@ -0,0 +1,46 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { config } from "./config"; + +const PREFIX = "enc:v1:"; +const IV_LENGTH = 12; + +function deriveKey(): Buffer { + return createHash("sha256").update(config.sessionSecret).digest(); +} + +export function isEncryptedSecret(value: string): boolean { + return value.startsWith(PREFIX); +} + +export function encryptSecret(value: string): string { + if (!value) return ""; + if (isEncryptedSecret(value)) return value; + + const iv = randomBytes(IV_LENGTH); + const key = deriveKey(); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`; +} + +export function decryptSecret(value: string): string { + if (!value) return ""; + if (!isEncryptedSecret(value)) return value; + + const payload = value.slice(PREFIX.length); + const [ivB64, tagB64, dataB64] = payload.split(":"); + if (!ivB64 || !tagB64 || !dataB64) { + throw new Error("Invalid encrypted secret format"); + } + + const iv = Buffer.from(ivB64, "base64"); + const tag = Buffer.from(tagB64, "base64"); + const data = Buffer.from(dataB64, "base64"); + const key = deriveKey(); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(data), decipher.final()]); + return plaintext.toString("utf8"); +}