mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-29 00:58:01 +03:00
fix: improve reverse proxy support for subdomain deployments (#237)
* fix: improve reverse proxy support for subdomain deployments (#63) - Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx from buffering the event stream - Auto-detect trusted origin from Host/X-Forwarded-* request headers so the app works behind a proxy without manual env var configuration - Add prominent reverse proxy documentation to advanced docs page explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments - Add reverse proxy env var comments and entries to both docker-compose.yml and docker-compose.alt.yml - Add dedicated reverse proxy configuration section to .env.example * fix: address review findings for reverse proxy origin detection - Fix x-forwarded-proto multi-value handling: take first value only and validate it is "http" or "https" before using - Update comment to accurately describe auto-detection scope: helps with per-request CSRF checks but not callback URL validation - Restore startup logging of static trusted origins for debugging * fix: handle multi-value x-forwarded-host in chained proxy setups x-forwarded-host can be comma-separated (e.g. "proxy1.example.com, proxy2.example.com") in chained proxy setups. Take only the first value, matching the same handling already applied to x-forwarded-proto. * test: add unit tests for reverse proxy origin detection Extract resolveTrustedOrigins into a testable exported function and add 11 tests covering: - Default localhost origins - BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars - Invalid URL handling - Auto-detection from x-forwarded-host + x-forwarded-proto - Multi-value header handling (chained proxy setups) - Invalid proto rejection (only http/https allowed) - Deduplication - Fallback to host header when x-forwarded-host absent
This commit is contained in:
19
.env.example
19
.env.example
@@ -18,9 +18,26 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
# ===========================================
|
||||
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
|
||||
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
|
||||
#
|
||||
# Set all three to your external URL, e.g.:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
#
|
||||
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
|
||||
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
|
||||
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
|
||||
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
# ===========================================
|
||||
# DOCKER CONFIGURATION (Optional)
|
||||
# ===========================================
|
||||
|
||||
@@ -18,6 +18,10 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
|
||||
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
|
||||
@@ -32,6 +32,13 @@ services:
|
||||
- PORT=4321
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
|
||||
# you MUST set these three variables to your external URL. Example:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
|
||||
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
|
||||
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
|
||||
# GitHub/Gitea Mirror Config
|
||||
|
||||
119
src/lib/auth-origins.test.ts
Normal file
119
src/lib/auth-origins.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { resolveTrustedOrigins } from "./auth";
|
||||
|
||||
// Helper to create a mock Request with specific headers
|
||||
function mockRequest(headers: Record<string, string>): Request {
|
||||
return new Request("http://localhost:4321/api/auth/sign-in", {
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveTrustedOrigins", () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and clear relevant env vars
|
||||
for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars
|
||||
for (const [key, val] of Object.entries(savedEnv)) {
|
||||
if (val === undefined) delete process.env[key];
|
||||
else process.env[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
test("includes localhost defaults when called without request", async () => {
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("http://localhost:4321");
|
||||
expect(origins).toContain("http://localhost:8080");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_URL from env", async () => {
|
||||
process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://gitea-mirror.example.com");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => {
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://a.example.com");
|
||||
expect(origins).toContain("https://b.example.com");
|
||||
});
|
||||
|
||||
test("skips invalid URLs in env vars", async () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).not.toContain("not-a-url");
|
||||
expect(origins).not.toContain("also-invalid");
|
||||
expect(origins).toContain("https://valid.example.com");
|
||||
});
|
||||
|
||||
test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea-mirror.mydomain.tld",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
|
||||
});
|
||||
|
||||
test("falls back to host header when x-forwarded-host is absent", async () => {
|
||||
const req = mockRequest({
|
||||
host: "myserver.local:4321",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://myserver.local:4321");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-host (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "external.example.com, internal.proxy.local",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://external.example.com");
|
||||
expect(origins).not.toContain("https://internal.proxy.local");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-proto (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "https, http",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea.example.com");
|
||||
// Should NOT create an origin with "https, http" as proto
|
||||
expect(origins).not.toContain("https, http://gitea.example.com");
|
||||
});
|
||||
|
||||
test("rejects invalid x-forwarded-proto values", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "ftp",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).not.toContain("ftp://gitea.example.com");
|
||||
});
|
||||
|
||||
test("deduplicates origins", async () => {
|
||||
process.env.BETTER_AUTH_URL = "http://localhost:4321";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
const count = origins.filter(o => o === "http://localhost:4321").length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("defaults proto to http when x-forwarded-proto is absent", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.internal",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://gitea.internal");
|
||||
});
|
||||
});
|
||||
113
src/lib/auth.ts
113
src/lib/auth.ts
@@ -6,6 +6,72 @@ import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
* Exported for testing. Called per-request with the incoming Request,
|
||||
* or at startup with no request (static origins only).
|
||||
*/
|
||||
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect origin from the incoming request's Host header when running
|
||||
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
|
||||
if (request?.headers) {
|
||||
// Take first value only — headers can be comma-separated in chained proxy setups
|
||||
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
|
||||
const host = rawHost?.split(",")[0].trim();
|
||||
if (host) {
|
||||
const rawProto = request.headers.get("x-forwarded-proto") || "http";
|
||||
const proto = rawProto.split(",")[0].trim().toLowerCase();
|
||||
if (proto === "http" || proto === "https") {
|
||||
try {
|
||||
const detected = new URL(`${proto}://${host}`);
|
||||
origins.push(detected.origin);
|
||||
} catch {
|
||||
// Malformed header, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
if (!request) {
|
||||
console.info("Trusted origins (static):", uniqueOrigins);
|
||||
}
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -43,48 +109,11 @@ export const auth = betterAuth({
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
// Trusted origins - this is how we support multiple access URLs.
|
||||
// Uses the function form so that the origin can be auto-detected from
|
||||
// the incoming request's Host / X-Forwarded-* headers, which makes the
|
||||
// app work behind a reverse proxy without manual env var configuration.
|
||||
trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no", // Prevent Nginx from buffering SSE stream
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -202,13 +202,55 @@ import MainLayout from '../../layouts/main.astro';
|
||||
<!-- Reverse Proxy Configuration -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
|
||||
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
|
||||
</p>
|
||||
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
|
||||
<div class="flex gap-3">
|
||||
<div class="text-red-600 dark:text-red-500">
|
||||
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-red-600 dark:text-red-500 mb-1">Required Environment Variables for Reverse Proxy</p>
|
||||
<p class="text-sm mb-3">
|
||||
When running Gitea Mirror behind a reverse proxy, you <strong>must</strong> set these environment variables to your external URL.
|
||||
Without them, sign-in will fail with "invalid origin" errors and pages may appear blank.
|
||||
</p>
|
||||
<div class="bg-muted/30 rounded p-3">
|
||||
<pre class="text-sm"><code>{`# All three MUST be set to your external URL:
|
||||
BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-1 text-sm">
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
|
||||
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4">Docker Compose Example</h3>
|
||||
|
||||
<div class="bg-muted/30 rounded-lg p-4 mb-6">
|
||||
<pre class="text-sm overflow-x-auto"><code>{`services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
environment:
|
||||
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
|
||||
- BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# ... other settings ...`}</code></pre>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
|
||||
|
||||
|
||||
<div class="bg-muted/30 rounded-lg p-4 mb-6">
|
||||
<pre class="text-sm overflow-x-auto"><code>{`server {
|
||||
listen 80;
|
||||
@@ -242,13 +284,16 @@ server {
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Cache-Control 'no-cache';
|
||||
proxy_set_header X-Accel-Buffering 'no';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}`}</code></pre>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
|
||||
|
||||
|
||||
<div class="bg-muted/30 rounded-lg p-4">
|
||||
<pre class="text-sm"><code>{`gitea-mirror.example.com {
|
||||
reverse_proxy localhost:4321
|
||||
|
||||
Reference in New Issue
Block a user