fix: resolve CVEs, upgrade to Astro v6, and harden API security (#227)

* fix: resolve CVEs, upgrade to Astro v6, and harden API security

Docker image CVE fixes:
- Install git-lfs v3.7.1 from GitHub releases (Go 1.25) instead of
  Debian apt (Go 1.23.12), fixing CVE-2025-68121 and 8 other Go stdlib CVEs
- Strip build-only packages (esbuild, vite, rollup, svgo, tailwindcss)
  from production image, eliminating 9 esbuild Go stdlib CVEs

Dependency upgrades:
- Astro v5 → v6 (includes Vite 7, Zod 4)
- Remove legacy content config (src/content/config.ts)
- Update HealthResponse type for simplified health endpoint
- npm overrides for fast-xml-parser ≥5.3.6, devalue ≥5.6.2,
  node-forge ≥1.3.2, svgo ≥4.0.1, rollup ≥4.59.0

API security hardening:
- /api/auth/debug: dev-only, require auth, remove user-creation POST,
  strip trustedOrigins/databaseConfig from response
- /api/auth/check-users: return boolean hasUsers instead of exact count
- /api/cleanup/auto: require authentication, remove per-user details
- /api/health: remove OS version, memory, uptime from response
- /api/config: validate Gitea URL protocol (http/https only)
- BETTER_AUTH_SECRET: log security warning when using insecure defaults
- generateRandomString: replace Math.random() with crypto.getRandomValues()
- hashValue: add random salt and timing-safe verification

* repositories: migrate table to tanstack

* Revert "repositories: migrate table to tanstack"

This reverts commit a544b29e6d.

* fixed lock file
This commit is contained in:
ARUNAVO RAY
2026-03-15 09:19:24 +05:30
committed by GitHub
parent 6f53a3ed41
commit 299659eca2
13 changed files with 668 additions and 774 deletions

View File

@@ -29,15 +29,34 @@ RUN bun install --production --omit=peer --frozen-lockfile
FROM oven/bun:1.3.10-debian AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git git-lfs wget sqlite3 openssl ca-certificates \
&& git lfs install \
&& rm -rf /var/lib/apt/lists/*
git wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& GIT_LFS_VERSION="3.7.1" \
&& ARCH="$(dpkg --print-architecture)" \
&& case "${ARCH}" in \
amd64) LFS_ARCH="amd64" ;; \
arm64) LFS_ARCH="arm64" ;; \
*) echo "Unsupported architecture: ${ARCH}" && exit 1 ;; \
esac \
&& wget -qO /tmp/git-lfs.tar.gz "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-${LFS_ARCH}-v${GIT_LFS_VERSION}.tar.gz" \
&& tar -xzf /tmp/git-lfs.tar.gz -C /tmp \
&& install -m 755 /tmp/git-lfs-${GIT_LFS_VERSION}/git-lfs /usr/local/bin/git-lfs \
&& rm -rf /tmp/git-lfs* \
&& git lfs install
COPY --from=pruner /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/drizzle ./drizzle
# Remove build-only packages that are not needed at runtime
# (esbuild, vite, rollup, tailwind, svgo — all only used during `astro build`)
RUN rm -rf node_modules/esbuild node_modules/@esbuild \
node_modules/rollup node_modules/@rollup \
node_modules/vite node_modules/svgo \
node_modules/@tailwindcss/vite \
node_modules/tailwindcss
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321

1120
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.12.5",
"version": "3.12.6",
"engines": {
"bun": ">=1.2.9"
},
@@ -44,14 +44,18 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.5.0"
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5",
"node-forge": "^1.3.3",
"svgo": "^4.0.1",
"rollup": ">=4.59.0"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/mdx": "4.3.13",
"@astrojs/node": "9.5.4",
"@astrojs/react": "^4.4.2",
"@better-auth/sso": "1.4.19",
"@astrojs/check": "^0.9.7",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.0.1",
"@astrojs/react": "^5.0.0",
"@better-auth/sso": "1.5.5",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.12",
@@ -78,9 +82,9 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^5.18.0",
"astro": "^6.0.4",
"bcryptjs": "^3.0.3",
"better-auth": "1.4.19",
"better-auth": "1.5.5",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -90,8 +94,8 @@
"drizzle-orm": "^0.45.1",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.575.0",
"nanoid": "^3.3.11",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -110,15 +114,15 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.9",
"@types/bun": "^1.3.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.2",
"@types/node": "^25.5.0",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "^0.31.9",
"jsdom": "^28.1.0",
"tsx": "^4.21.0",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
},
"packageManager": "bun@1.3.10"
}

View File

@@ -1,4 +0,0 @@
import { defineCollection, z } from 'astro:content';
// Export empty collections since docs have been moved
export const collections = {};

View File

@@ -91,35 +91,17 @@ export const giteaApi = {
// Health API
export interface HealthResponse {
status: "ok" | "error";
status: "ok" | "error" | "degraded";
timestamp: string;
version: string;
latestVersion: string;
updateAvailable: boolean;
database: {
connected: boolean;
message: string;
};
system: {
uptime: {
startTime: string;
uptimeMs: number;
formatted: string;
};
memory: {
rss: string;
heapTotal: string;
heapUsed: string;
external: string;
systemTotal: string;
systemFree: string;
};
os: {
platform: string;
version: string;
arch: string;
};
env: string;
recovery?: {
status: string;
jobsNeedingRecovery: number;
};
error?: string;
}

View File

@@ -19,8 +19,23 @@ export const ENV = {
},
// Better Auth secret for authentication
BETTER_AUTH_SECRET:
process.env.BETTER_AUTH_SECRET || "your-secret-key-change-this-in-production",
get BETTER_AUTH_SECRET(): string {
const secret = process.env.BETTER_AUTH_SECRET;
const knownInsecureDefaults = [
"your-secret-key-change-this-in-production",
"dev-only-insecure-secret-do-not-use-in-production",
];
if (!secret || knownInsecureDefaults.includes(secret)) {
if (process.env.NODE_ENV === "production") {
console.error(
"\x1b[31m[SECURITY WARNING]\x1b[0m BETTER_AUTH_SECRET is missing or using an insecure default. " +
"Set a strong secret: openssl rand -base64 32"
);
}
return secret || "dev-only-insecure-secret-do-not-use-in-production";
}
return secret;
},
// Server host and port
HOST: process.env.HOST || "localhost",

View File

@@ -11,9 +11,11 @@ export function cn(...inputs: ClassValue[]) {
export function generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const randomValues = new Uint32Array(length);
crypto.getRandomValues(randomValues);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
result += chars.charAt(randomValues[i] % chars.length);
}
return result;
}

View File

@@ -160,10 +160,23 @@ export function generateSecureToken(length: number = 32): string {
}
/**
* Hashes a value using SHA-256 (for non-reversible values like API keys for comparison)
* Hashes a value using SHA-256 with a random salt (for non-reversible values like API keys)
* @param value The value to hash
* @returns Hex encoded hash
* @returns Salt and hash in format "salt:hash"
*/
export function hashValue(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.createHash('sha256').update(salt + value).digest('hex');
return `${salt}:${hash}`;
}
/**
* Verifies a value against a salted hash produced by hashValue()
* Uses constant-time comparison to prevent timing attacks
*/
export function verifyHash(value: string, saltedHash: string): boolean {
const [salt, expectedHash] = saltedHash.split(':');
if (!salt || !expectedHash) return false;
const actualHash = crypto.createHash('sha256').update(salt + value).digest('hex');
return crypto.timingSafeEqual(Buffer.from(actualHash, 'hex'), Buffer.from(expectedHash, 'hex'));
}

View File

@@ -7,17 +7,10 @@ export const GET: APIRoute = async () => {
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const hasUsers = userCountResult[0].count > 0;
return new Response(JSON.stringify({ userCount }), {
return new Response(JSON.stringify({ hasUsers }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -27,4 +20,4 @@ export const GET: APIRoute = async () => {
headers: { "Content-Type": "application/json" },
});
}
};
};

View File

@@ -1,79 +1,42 @@
import type { APIRoute } from "astro";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import { ENV } from "@/lib/config";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
// Only available in development
if (ENV.NODE_ENV === "production") {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
export const GET: APIRoute = async ({ request }) => {
try {
// Get Better Auth configuration info
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const info = {
baseURL: auth.options.baseURL,
basePath: auth.options.basePath,
trustedOrigins: auth.options.trustedOrigins,
emailPasswordEnabled: auth.options.emailAndPassword?.enabled,
userFields: auth.options.user?.additionalFields,
databaseConfig: {
usePlural: true,
provider: "sqlite"
}
};
return new Response(JSON.stringify({
success: true,
config: info
config: info,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
error: "An unexpected error occurred",
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
export const POST: APIRoute = async ({ request }) => {
try {
// Test creating a user directly
const userId = nanoid();
const now = new Date();
await db.insert(users).values({
id: userId,
email: "test2@example.com",
emailVerified: false,
username: "test2",
// Let the database handle timestamps with defaults
});
return new Response(JSON.stringify({
success: true,
userId,
message: "User created successfully"
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -6,19 +6,23 @@
import type { APIRoute } from 'astro';
import { runAutomaticCleanup } from '@/lib/cleanup-service';
import { createSecureErrorResponse } from '@/lib/utils';
import { requireAuthenticatedUserId } from '@/lib/auth-guards';
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
console.log('Manual cleanup trigger requested');
// Run the automatic cleanup
const results = await runAutomaticCleanup();
// Calculate totals
const totalEventsDeleted = results.reduce((sum, result) => sum + result.eventsDeleted, 0);
const totalJobsDeleted = results.reduce((sum, result) => sum + result.mirrorJobsDeleted, 0);
const errors = results.filter(result => result.error);
return new Response(
JSON.stringify({
success: true,
@@ -28,7 +32,6 @@ export const POST: APIRoute = async ({ request }) => {
totalEventsDeleted,
totalJobsDeleted,
errors: errors.length,
details: results,
},
}),
{

View File

@@ -38,6 +38,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Validate Gitea URL format and protocol
if (giteaConfig.url) {
try {
const giteaUrl = new URL(giteaConfig.url);
if (!['http:', 'https:'].includes(giteaUrl.protocol)) {
return new Response(
JSON.stringify({ success: false, message: "Gitea URL must use http or https protocol." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
} catch {
return new Response(
JSON.stringify({ success: false, message: "Invalid Gitea URL format." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}
// Fetch existing config
const existingConfigResult = await db
.select()

View File

@@ -1,14 +1,9 @@
import type { APIRoute } from "astro";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { db } from "@/lib/db";
import { ENV } from "@/lib/config";
import { getRecoveryStatus, hasJobsNeedingRecovery } from "@/lib/recovery";
import os from "os";
import { httpGet } from "@/lib/http-client";
// Track when the server started
const serverStartTime = new Date();
// Cache for the latest version to avoid frequent GitHub API calls
interface VersionCache {
latestVersion: string;
@@ -23,18 +18,6 @@ export const GET: APIRoute = async () => {
// Check database connection by running a simple query
const dbStatus = await checkDatabaseConnection();
// Get system information
const systemInfo = {
uptime: getUptime(),
memory: getMemoryUsage(),
os: {
platform: os.platform(),
version: os.version(),
arch: os.arch(),
},
env: ENV.NODE_ENV,
};
// Get current and latest versions
const currentVersion = process.env.npm_package_version || "unknown";
const latestVersion = await checkLatestVersion();
@@ -50,7 +33,7 @@ export const GET: APIRoute = async () => {
overallStatus = "degraded";
}
// Build response
// Build response (no OS/memory details to avoid information disclosure)
const healthData = {
status: overallStatus,
timestamp: new Date().toISOString(),
@@ -59,9 +42,11 @@ export const GET: APIRoute = async () => {
updateAvailable: latestVersion !== "unknown" &&
currentVersion !== "unknown" &&
compareVersions(currentVersion, latestVersion) < 0,
database: dbStatus,
recovery: recoveryStatus,
system: systemInfo,
database: { connected: dbStatus.connected },
recovery: {
status: recoveryStatus.status,
jobsNeedingRecovery: recoveryStatus.jobsNeedingRecovery,
},
};
return jsonResponse({
@@ -125,55 +110,6 @@ async function getRecoverySystemStatus() {
}
}
/**
* Get server uptime information
*/
function getUptime() {
const now = new Date();
const uptimeMs = now.getTime() - serverStartTime.getTime();
// Convert to human-readable format
const seconds = Math.floor(uptimeMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return {
startTime: serverStartTime.toISOString(),
uptimeMs,
formatted: `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`,
};
}
/**
* Get memory usage information
*/
function getMemoryUsage() {
const memoryUsage = process.memoryUsage();
return {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external),
systemTotal: formatBytes(os.totalmem()),
systemFree: formatBytes(os.freemem()),
};
}
/**
* Format bytes to human-readable format
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Compare semantic versions
* Returns: