Compare commits
9 Commits
5a77ae5084
...
security-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc92b8f27 | ||
|
|
749ad4a694 | ||
|
|
0f752acae5 | ||
|
|
652bd220c2 | ||
|
|
9f2eaaf04e | ||
|
|
63d3f0e86c | ||
|
|
25e7d234ba | ||
|
|
7a3f734728 | ||
|
|
d59a07a8c5 |
496
Security-Audit.md
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
Date: 2025-11-07Application: Gitea Mirror v3.9.0Branch: bun-v1.3.1Scope: Full application security review
|
||||||
|
|
||||||
|
---
|
||||||
|
Executive Summary
|
||||||
|
|
||||||
|
A comprehensive security audit was conducted across all components of the Gitea Mirror application, including authentication, authorization,
|
||||||
|
encryption, input validation, API endpoints, database operations, UI components, and external integrations. The review identified 23 security
|
||||||
|
findings across multiple severity levels.
|
||||||
|
|
||||||
|
Critical Findings: 2
|
||||||
|
|
||||||
|
High Severity: 9
|
||||||
|
|
||||||
|
Medium Severity: 10
|
||||||
|
|
||||||
|
Low Severity: 2
|
||||||
|
|
||||||
|
Overall Security Posture: The application demonstrates strong foundational security practices (AES-256-GCM encryption, Drizzle ORM parameterized
|
||||||
|
queries, React auto-escaping) but has critical authorization flaws that require immediate remediation.
|
||||||
|
|
||||||
|
---
|
||||||
|
CRITICAL SEVERITY VULNERABILITIES
|
||||||
|
|
||||||
|
Vuln 1: Broken Object Level Authorization (BOLA) - Multiple Endpoints
|
||||||
|
|
||||||
|
Severity: CRITICALConfidence: 1.0Category: Authorization Bypass
|
||||||
|
|
||||||
|
Affected Files:
|
||||||
|
- src/pages/api/config/index.ts:18
|
||||||
|
- src/pages/api/job/mirror-repo.ts:16
|
||||||
|
- src/pages/api/github/repositories.ts:11
|
||||||
|
- src/pages/api/dashboard/index.ts:9
|
||||||
|
- src/pages/api/activities/index.ts:8
|
||||||
|
- src/pages/api/sync/repository.ts:15
|
||||||
|
- src/pages/api/sync/organization.ts:14
|
||||||
|
- src/pages/api/job/mirror-org.ts:14
|
||||||
|
- src/pages/api/job/retry-repo.ts:18
|
||||||
|
- src/pages/api/job/sync-repo.ts:11
|
||||||
|
- src/pages/api/job/schedule-sync-repo.ts:13
|
||||||
|
|
||||||
|
Description: Multiple API endpoints accept a userId parameter from client requests without validating that the authenticated user matches the
|
||||||
|
requested userId. This allows any authenticated user to access and manipulate any other user's data.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
POST /api/config/index
|
||||||
|
Content-Type: application/json
|
||||||
|
Cookie: better-auth-session=attacker-session
|
||||||
|
|
||||||
|
{
|
||||||
|
"userId": "victim-user-id",
|
||||||
|
"giteaConfig": {
|
||||||
|
"token": "attacker-controlled-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result: Attacker modifies victim's configuration, steals encrypted tokens, triggers mirror operations, or accesses activity logs.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
export const POST: APIRoute = async (context) => {
|
||||||
|
// 1. Validate authentication
|
||||||
|
const { user, response } = await requireAuth(context);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
// 2. Use authenticated userId from session, NOT from request body
|
||||||
|
const authenticatedUserId = user!.id;
|
||||||
|
|
||||||
|
// 3. Don't accept userId from client
|
||||||
|
const body = await request.json();
|
||||||
|
const { githubConfig, giteaConfig } = body;
|
||||||
|
|
||||||
|
// 4. Use session userId for all operations
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(configs)
|
||||||
|
.where(eq(configs.userId, authenticatedUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 2: Header Authentication Spoofing - src/lib/auth-header.ts:48
|
||||||
|
|
||||||
|
Severity: CRITICALConfidence: 0.95Category: Authentication Bypass
|
||||||
|
|
||||||
|
Description: The header authentication mechanism trusts HTTP headers (X-Authentik-Username, X-Authentik-Email) without proper validation. If the
|
||||||
|
application is accessible without a properly configured reverse proxy, attackers can send these headers directly to impersonate any user.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
GET /api/config/index?userId=admin
|
||||||
|
Host: gitea-mirror.example.com
|
||||||
|
X-Authentik-Username: admin
|
||||||
|
X-Authentik-Email: admin@example.com
|
||||||
|
|
||||||
|
Result: Attacker gains admin access without authentication.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// 1. Add trusted proxy IP validation
|
||||||
|
const TRUSTED_PROXY_IPS = process.env.TRUSTED_PROXY_IPS?.split(',') || [];
|
||||||
|
|
||||||
|
export function extractUserFromHeaders(headers: Headers, remoteIp: string): UserInfo | null {
|
||||||
|
// Validate request comes from trusted reverse proxy
|
||||||
|
if (TRUSTED_PROXY_IPS.length > 0 && !TRUSTED_PROXY_IPS.includes(remoteIp)) {
|
||||||
|
console.warn(`Header auth rejected: untrusted IP ${remoteIp}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add shared secret validation
|
||||||
|
const authSecret = headers.get('X-Auth-Secret');
|
||||||
|
if (authSecret !== process.env.HEADER_AUTH_SECRET) {
|
||||||
|
console.warn('Header auth rejected: invalid secret');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with header extraction...
|
||||||
|
}
|
||||||
|
|
||||||
|
Additional Requirements:
|
||||||
|
- Document that header auth MUST only be used behind a reverse proxy
|
||||||
|
- Reverse proxy MUST strip X-Authentik-* headers from untrusted requests
|
||||||
|
- Direct access MUST be blocked via firewall rules
|
||||||
|
|
||||||
|
---
|
||||||
|
HIGH SEVERITY VULNERABILITIES
|
||||||
|
|
||||||
|
Vuln 3: Missing Authentication on Critical Endpoints
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 1.0Category: Authentication Bypass
|
||||||
|
|
||||||
|
Affected Endpoints:
|
||||||
|
- /api/github/test-connection - Tests GitHub connection with user's token
|
||||||
|
- /api/gitea/test-connection - Tests Gitea connection with user's token
|
||||||
|
- /api/rate-limit/index - Exposes rate limit info
|
||||||
|
- /api/events/index - SSE endpoint for events
|
||||||
|
- /api/activities/cleanup - Deletes activities
|
||||||
|
|
||||||
|
Description: Several sensitive endpoints lack authentication checks entirely.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
POST /api/github/test-connection
|
||||||
|
{"token": "stolen-token", "userId": "victim"}
|
||||||
|
|
||||||
|
Recommendation: Add requireAuth to all sensitive endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 4: Token Exposure Through Config API - src/pages/api/config/index.ts:220
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.9Category: Sensitive Data Exposure
|
||||||
|
|
||||||
|
Description: The GET /api/config/index endpoint decrypts and returns GitHub/Gitea API tokens in plaintext. Combined with BOLA, any user can retrieve
|
||||||
|
any other user's tokens.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
1. Attacker authenticates as User A
|
||||||
|
2. Calls /api/config/index?userId=admin
|
||||||
|
3. Receives admin's decrypted GitHub Personal Access Token
|
||||||
|
4. Uses stolen token to access admin's repositories
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// Never send full tokens - use masked versions
|
||||||
|
if (githubConfig.token) {
|
||||||
|
const decrypted = decrypt(githubConfig.token);
|
||||||
|
githubConfig.token = maskToken(decrypted); // "ghp_****...last4"
|
||||||
|
githubConfig.tokenSet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 5: Static Salt in Key Derivation - src/lib/utils/encryption.ts:21
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.95Category: Cryptographic Weakness
|
||||||
|
|
||||||
|
Description: The key derivation function uses a static, hardcoded salt that reduces protection against precomputation attacks.
|
||||||
|
|
||||||
|
Exploit Scenario: If ENCRYPTION_SECRET is weak or compromised, the static salt makes brute-force attacks more efficient. Attackers can build targeted
|
||||||
|
rainbow tables.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// Generate and store unique salt per installation
|
||||||
|
const SALT_FILE = '/app/data/.encryption_salt';
|
||||||
|
let installationSalt: Buffer;
|
||||||
|
|
||||||
|
if (fs.existsSync(SALT_FILE)) {
|
||||||
|
installationSalt = Buffer.from(fs.readFileSync(SALT_FILE, 'utf8'), 'hex');
|
||||||
|
} else {
|
||||||
|
installationSalt = crypto.randomBytes(32);
|
||||||
|
fs.writeFileSync(SALT_FILE, installationSalt.toString('hex'));
|
||||||
|
fs.chmodSync(SALT_FILE, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.pbkdf2Sync(secret, installationSalt, ITERATIONS, KEY_LENGTH, 'sha256');
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 6: Weak Default Secret - src/lib/config.ts:22
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.90Category: Hardcoded Credentials
|
||||||
|
|
||||||
|
Description: The application uses a hardcoded default for BETTER_AUTH_SECRET when not set.
|
||||||
|
|
||||||
|
Exploit Scenario: Non-Docker deployments may use the weak default. Attackers can forge authentication tokens using the known default secret.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
BETTER_AUTH_SECRET: (() => {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
const weakDefaults = ["your-secret-key-change-this-in-production"];
|
||||||
|
|
||||||
|
if (!secret || weakDefaults.includes(secret)) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET required. Generate with: openssl rand -base64 32");
|
||||||
|
}
|
||||||
|
return "dev-secret-minimum-32-chars";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret;
|
||||||
|
})()
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 7: Unencrypted OAuth Client Secrets - src/lib/db/schema.ts:569
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.92Category: Sensitive Data Exposure
|
||||||
|
|
||||||
|
Description: OAuth application client secrets and SSO provider credentials are stored as plaintext, inconsistent with encrypted GitHub/Gitea tokens.
|
||||||
|
|
||||||
|
Exploit Scenario: Database leak exposes OAuth client secrets, allowing attackers to impersonate the application to external OAuth providers.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// Encrypt before storage
|
||||||
|
import { encrypt } from "@/lib/utils/encryption";
|
||||||
|
|
||||||
|
await db.insert(oauthApplications).values({
|
||||||
|
clientSecret: encrypt(clientSecret),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add decryption helper
|
||||||
|
export function decryptOAuthClientSecret(encrypted: string): string {
|
||||||
|
return decrypt(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 8: SSRF in OIDC Discovery - src/pages/api/sso/discover.ts:48
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.95Category: Server-Side Request Forgery
|
||||||
|
|
||||||
|
Description: The endpoint accepts user-provided issuer URL and fetches ${issuer}/.well-known/openid-configuration without validating against internal
|
||||||
|
networks.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
POST /api/sso/discover
|
||||||
|
{"issuer": "http://169.254.169.254/latest/meta-data"}
|
||||||
|
|
||||||
|
Result: Access to AWS metadata service, internal APIs, or port scanning.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// Block private IPs and localhost
|
||||||
|
const ALLOWED_PROTOCOLS = ['https:'];
|
||||||
|
const hostname = parsedIssuer.hostname.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ALLOWED_PROTOCOLS.includes(parsedIssuer.protocol) ||
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname.match(/^10\./) ||
|
||||||
|
hostname.match(/^192\.168\./) ||
|
||||||
|
hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
|
||||||
|
hostname.match(/^169\.254\./) ||
|
||||||
|
hostname.match(/^127\./)
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid issuer" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 9: SSRF in Gitea Connection Test - src/pages/api/gitea/test-connection.ts:29
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.95Category: SSRF / Input Validation
|
||||||
|
|
||||||
|
Description: Accepts user-provided url and makes HTTP request without validation.
|
||||||
|
|
||||||
|
Exploit Scenario: Same as Vuln 8 - internal network scanning, metadata service access.
|
||||||
|
|
||||||
|
Recommendation: Apply same URL validation as Vuln 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 10: Command Injection in Docker Build - scripts/build-docker.sh:72
|
||||||
|
|
||||||
|
Severity: HIGHConfidence: 0.95Category: Command Injection
|
||||||
|
|
||||||
|
Description: Uses eval with dynamically constructed Docker command including environment variables.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
DOCKER_IMAGE='test; rm -rf /; #'
|
||||||
|
./scripts/build-docker.sh
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
# Replace eval with direct execution
|
||||||
|
if docker buildx build --platform linux/amd64,linux/arm64 \
|
||||||
|
-t "$FULL_IMAGE_NAME" \
|
||||||
|
${LOAD:+--load} \
|
||||||
|
${PUSH:+--push} \
|
||||||
|
.; then
|
||||||
|
echo "Success"
|
||||||
|
fi
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 11: Path Traversal in Gitea API - src/lib/gitea.ts:193
|
||||||
|
|
||||||
|
Severity: MEDIUM-HIGHConfidence: 0.85Category: Path Traversal / SSRF
|
||||||
|
|
||||||
|
Description: Repository owner/name interpolated into URLs without encoding.
|
||||||
|
|
||||||
|
Exploit Scenario:
|
||||||
|
owner = "../../admin"
|
||||||
|
repoName = "users/../../../config"
|
||||||
|
// Results in: /api/v1/repos/../../admin/users/../../../config
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
const response = await fetch(
|
||||||
|
`${config.url}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`,
|
||||||
|
// ...
|
||||||
|
);
|
||||||
|
|
||||||
|
Apply to all URL constructions in gitea.ts and gitea-enhanced.ts.
|
||||||
|
|
||||||
|
---
|
||||||
|
MEDIUM SEVERITY VULNERABILITIES
|
||||||
|
|
||||||
|
Vuln 12: Weak Session Configuration - src/lib/auth.ts:105
|
||||||
|
|
||||||
|
Severity: MEDIUMConfidence: 0.85
|
||||||
|
|
||||||
|
Description: 30-day session expiration is excessive; missing explicit security flags.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days max
|
||||||
|
cookieOptions: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
httpOnly: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 13: Missing CSRF Protection
|
||||||
|
|
||||||
|
Severity: MEDIUMConfidence: 0.80
|
||||||
|
|
||||||
|
Description: No visible CSRF token validation on state-changing operations.
|
||||||
|
|
||||||
|
Recommendation: Verify Better Auth CSRF protection is enabled; implement explicit tokens if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 14: Weak Random Number Generation - src/lib/utils.ts:12
|
||||||
|
|
||||||
|
Severity: MEDIUMConfidence: 0.85
|
||||||
|
|
||||||
|
Description: generateRandomString() uses Math.random() (not cryptographically secure) for OAuth client credentials.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
// Use existing secure function
|
||||||
|
const clientId = `client_${generateSecureToken(16)}`;
|
||||||
|
const clientSecret = `secret_${generateSecureToken(24)}`;
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 15-18: Input Validation Issues
|
||||||
|
|
||||||
|
Severity: MEDIUMConfidence: 0.85
|
||||||
|
|
||||||
|
Affected:
|
||||||
|
- src/pages/api/repositories/[id].ts:24 - destinationOrg not validated
|
||||||
|
- src/pages/api/organizations/[id].ts:24 - destinationOrg not validated
|
||||||
|
- src/pages/api/job/mirror-repo.ts:19 - repositoryIds array not validated
|
||||||
|
- src/lib/helpers.ts:49 - Repository names in messages not sanitized
|
||||||
|
|
||||||
|
Recommendation: Use Zod schemas for all user inputs:
|
||||||
|
const updateRepoSchema = z.object({
|
||||||
|
destinationOrg: z.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9\-_\.]+$/)
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
---
|
||||||
|
LOW SEVERITY VULNERABILITIES
|
||||||
|
|
||||||
|
Vuln 19: XSS via Astro set:html - src/pages/docs/quickstart.astro:102
|
||||||
|
|
||||||
|
Severity: LOWConfidence: 0.85
|
||||||
|
|
||||||
|
Description: Uses set:html with static data; risky pattern if copied with dynamic content.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
<p class="text-sm" set:text={item.text}></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
Vuln 20: Open Redirect in OAuth - src/components/oauth/ConsentPage.tsx:118
|
||||||
|
|
||||||
|
Severity: LOWConfidence: 0.90
|
||||||
|
|
||||||
|
Description: OAuth redirect uses window.location.href without explicit protocol validation.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw new Error('Invalid protocol');
|
||||||
|
}
|
||||||
|
window.location.assign(url.toString());
|
||||||
|
|
||||||
|
---
|
||||||
|
POSITIVE SECURITY FINDINGS ✅
|
||||||
|
|
||||||
|
The codebase demonstrates excellent security practices:
|
||||||
|
|
||||||
|
1. SQL Injection Protection - Drizzle ORM with parameterized queries throughout
|
||||||
|
2. Token Encryption - AES-256-GCM for GitHub/Gitea tokens
|
||||||
|
3. XSS Protection - React auto-escaping, no dangerouslySetInnerHTML found
|
||||||
|
4. Password Hashing - bcrypt via Better Auth
|
||||||
|
5. Secure Random - crypto.randomBytes() in encryption functions
|
||||||
|
6. Input Validation - Comprehensive Zod schemas
|
||||||
|
7. Safe Path Operations - path.join() with proper base directories
|
||||||
|
8. No Command Execution - TypeScript codebase doesn't use child_process
|
||||||
|
9. Authentication Framework - Better Auth with session management
|
||||||
|
10. Type Safety - TypeScript strict mode
|
||||||
|
|
||||||
|
---
|
||||||
|
REMEDIATION PRIORITY
|
||||||
|
|
||||||
|
Phase 1: EMERGENCY (Deploy Today)
|
||||||
|
|
||||||
|
1. Fix BOLA vulnerabilities - Add authentication and userId validation (Vuln 1)
|
||||||
|
2. Disable or secure header auth - Add IP whitelist validation (Vuln 2)
|
||||||
|
3. Add authentication checks - Protect unprotected endpoints (Vuln 3)
|
||||||
|
|
||||||
|
Phase 2: URGENT (This Week)
|
||||||
|
|
||||||
|
1. Implement token masking - Don't return decrypted tokens (Vuln 4)
|
||||||
|
2. Fix SSRF vulnerabilities - Add URL validation (Vuln 8, 9)
|
||||||
|
3. Fix command injection - Remove eval from build script (Vuln 10)
|
||||||
|
4. Add URL encoding - Path traversal fix (Vuln 11)
|
||||||
|
|
||||||
|
Phase 3: HIGH (This Sprint)
|
||||||
|
|
||||||
|
1. Fix KDF salt - Generate unique salt per installation (Vuln 5)
|
||||||
|
2. Enforce strong secrets - Fail if weak defaults used (Vuln 6)
|
||||||
|
3. Encrypt OAuth secrets - Match GitHub/Gitea token encryption (Vuln 7)
|
||||||
|
4. Reduce session expiration - 7 days maximum (Vuln 12)
|
||||||
|
5. Add CSRF protection - Explicit tokens (Vuln 13)
|
||||||
|
|
||||||
|
Phase 4: MEDIUM (Next Sprint)
|
||||||
|
|
||||||
|
1. Input validation - Zod schemas for all endpoints (Vuln 14-18)
|
||||||
|
2. Fix weak RNG - Use crypto.randomBytes (Vuln 14)
|
||||||
|
|
||||||
|
Phase 5: LOW (Backlog)
|
||||||
|
|
||||||
|
1. Remove set:html - Use safer alternatives (Vuln 19)
|
||||||
|
2. OAuth redirect validation - Add protocol check (Vuln 20)
|
||||||
|
|
||||||
|
---
|
||||||
|
TESTING RECOMMENDATIONS
|
||||||
|
|
||||||
|
Proof of Concept Tests
|
||||||
|
|
||||||
|
# Test BOLA
|
||||||
|
curl -X POST http://localhost:4321/api/config/index \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Cookie: better-auth-session=<attacker-session>" \
|
||||||
|
-d '{"userId":"victim-id","githubConfig":{...}}'
|
||||||
|
|
||||||
|
# Test header spoofing
|
||||||
|
curl http://localhost:4321/api/config/index?userId=admin \
|
||||||
|
-H "X-Authentik-Username: admin" \
|
||||||
|
-H "X-Authentik-Email: admin@example.com"
|
||||||
|
|
||||||
|
# Test SSRF
|
||||||
|
curl -X POST http://localhost:4321/api/sso/discover \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"issuer":"http://169.254.169.254/latest/meta-data"}'
|
||||||
|
|
||||||
|
---
|
||||||
|
SECURITY GRADE
|
||||||
|
|
||||||
|
Overall Security Grade: C+
|
||||||
|
- Would be B+ after Phase 1-2 fixes
|
||||||
|
- Would be A- after all fixes
|
||||||
|
|
||||||
|
Critical Issues: 2 (authorization bypass, auth spoofing)Risk Level: HIGH due to BOLA allowing complete account takeover
|
||||||
|
|
||||||
|
---
|
||||||
|
CONCLUSION
|
||||||
|
|
||||||
|
The Gitea Mirror application has a strong security foundation with proper encryption, ORM usage, and XSS protection. However, critical authorization
|
||||||
|
flaws in API endpoints allow authenticated users to access any other user's data. These must be fixed immediately before production deployment.
|
||||||
|
|
||||||
|
The development team has clearly prioritized security (encryption, input validation, type safety), and the identified issues are fixable with
|
||||||
|
moderate effort. After remediation, this will be a secure, production-ready application.
|
||||||
1
drizzle/0008_serious_thena.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `repositories` ADD `metadata` text;
|
||||||
2006
drizzle/meta/0008_snapshot.json
Normal file
@@ -57,6 +57,13 @@
|
|||||||
"when": 1761534391115,
|
"when": 1761534391115,
|
||||||
"tag": "0007_whole_hellion",
|
"tag": "0007_whole_hellion",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1761802056073,
|
||||||
|
"tag": "0008_serious_thena",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea-mirror",
|
"name": "gitea-mirror",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "3.8.11",
|
"version": "3.9.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.2.9"
|
"bun": ">=1.2.9"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -164,6 +164,7 @@ export const repositorySchema = z.object({
|
|||||||
lastMirrored: z.coerce.date().optional().nullable(),
|
lastMirrored: z.coerce.date().optional().nullable(),
|
||||||
errorMessage: z.string().optional().nullable(),
|
errorMessage: z.string().optional().nullable(),
|
||||||
destinationOrg: z.string().optional().nullable(),
|
destinationOrg: z.string().optional().nullable(),
|
||||||
|
metadata: z.string().optional().nullable(), // JSON string for metadata sync state
|
||||||
createdAt: z.coerce.date(),
|
createdAt: z.coerce.date(),
|
||||||
updatedAt: z.coerce.date(),
|
updatedAt: z.coerce.date(),
|
||||||
});
|
});
|
||||||
@@ -376,6 +377,8 @@ export const repositories = sqliteTable("repositories", {
|
|||||||
|
|
||||||
destinationOrg: text("destination_org"),
|
destinationOrg: text("destination_org"),
|
||||||
|
|
||||||
|
metadata: text("metadata"), // JSON string storing metadata sync state (issues, PRs, releases, etc.)
|
||||||
|
|
||||||
createdAt: integer("created_at", { mode: "timestamp" })
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(unixepoch())`),
|
.default(sql`(unixepoch())`),
|
||||||
|
|||||||
@@ -2005,6 +2005,12 @@ export async function mirrorGitHubReleasesToGitea({
|
|||||||
.slice(0, releaseLimit)
|
.slice(0, releaseLimit)
|
||||||
.sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b));
|
.sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b));
|
||||||
|
|
||||||
|
console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest)`);
|
||||||
|
releasesToProcess.forEach((rel, idx) => {
|
||||||
|
const date = new Date(rel.published_at || rel.created_at);
|
||||||
|
console.log(`[Releases] ${idx + 1}. ${rel.tag_name} - Originally published: ${date.toISOString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
for (const release of releasesToProcess) {
|
for (const release of releasesToProcess) {
|
||||||
try {
|
try {
|
||||||
// Check if release already exists
|
// Check if release already exists
|
||||||
@@ -2015,8 +2021,14 @@ export async function mirrorGitHubReleasesToGitea({
|
|||||||
}
|
}
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
|
|
||||||
const releaseNote = release.body || "";
|
// Prepare release body with GitHub original date header
|
||||||
|
const githubPublishedDate = release.published_at || release.created_at;
|
||||||
|
const githubDateHeader = githubPublishedDate
|
||||||
|
? `> 📅 **Originally published on GitHub:** ${new Date(githubPublishedDate).toUTCString()}\n\n`
|
||||||
|
: '';
|
||||||
|
const originalReleaseNote = release.body || "";
|
||||||
|
const releaseNote = githubDateHeader + originalReleaseNote;
|
||||||
|
|
||||||
if (existingReleasesResponse) {
|
if (existingReleasesResponse) {
|
||||||
// Update existing release if the changelog/body differs
|
// Update existing release if the changelog/body differs
|
||||||
const existingRelease = existingReleasesResponse.data;
|
const existingRelease = existingReleasesResponse.data;
|
||||||
@@ -2039,9 +2051,11 @@ export async function mirrorGitHubReleasesToGitea({
|
|||||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (releaseNote) {
|
if (originalReleaseNote) {
|
||||||
console.log(`[Releases] Updated changelog for ${release.tag_name} (${releaseNote.length} characters)`);
|
console.log(`[Releases] Updated changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Releases] Updated release ${release.tag_name} with GitHub date header`);
|
||||||
}
|
}
|
||||||
mirroredCount++;
|
mirroredCount++;
|
||||||
} else {
|
} else {
|
||||||
@@ -2051,9 +2065,11 @@ export async function mirrorGitHubReleasesToGitea({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new release with changelog/body content
|
// Create new release with changelog/body content (includes GitHub date header)
|
||||||
if (releaseNote) {
|
if (originalReleaseNote) {
|
||||||
console.log(`[Releases] Including changelog for ${release.tag_name} (${releaseNote.length} characters)`);
|
console.log(`[Releases] Including changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Releases] Creating release ${release.tag_name} with GitHub date header (no changelog)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createReleaseResponse = await httpPost(
|
const createReleaseResponse = await httpPost(
|
||||||
@@ -2121,8 +2137,14 @@ export async function mirrorGitHubReleasesToGitea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
mirroredCount++;
|
mirroredCount++;
|
||||||
const noteInfo = releaseNote ? ` with ${releaseNote.length} character changelog` : " without changelog";
|
const noteInfo = originalReleaseNote ? ` with ${originalReleaseNote.length} character changelog` : " without changelog";
|
||||||
console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`);
|
console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`);
|
||||||
|
|
||||||
|
// Add delay to ensure proper timestamp ordering in Gitea
|
||||||
|
// Gitea sorts releases by created_unix DESC, and all releases created in quick succession
|
||||||
|
// will have nearly identical timestamps. The 1-second delay ensures proper chronological order.
|
||||||
|
console.log(`[Releases] Waiting 1 second to ensure proper timestamp ordering in Gitea...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Releases] Failed to mirror release ${release.tag_name}: ${error instanceof Error ? error.message : String(error)}`);
|
console.error(`[Releases] Failed to mirror release ${release.tag_name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
www/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
3
www/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 330 KiB |
@@ -22,7 +22,7 @@ export function Installation() {
|
|||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: "Clone the repository",
|
title: "Clone the repository",
|
||||||
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git\ncd gitea-mirror",
|
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git && cd gitea-mirror",
|
||||||
id: "docker-clone"
|
id: "docker-clone"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -166,4 +166,4 @@ export function Installation() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||