allow controlling cors from config and add cors middleware to traefik

This commit is contained in:
Milo Schwartz 2025-01-13 23:59:10 -05:00
parent 7ff5376d13
commit ab18e15a71
No known key found for this signature in database
8 changed files with 98 additions and 43 deletions

View file

@ -1,15 +1,15 @@
app: app:
dashboard_url: http://localhost dashboard_url: http://localhost:3002
base_domain: localhost base_domain: localhost
log_level: debug log_level: info
save_logs: false save_logs: false
server: server:
external_port: 3000 external_port: 3000
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: localhost internal_hostname: pangolin
secure_cookies: false secure_cookies: true
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token resource_access_token_param: p_token
@ -38,4 +38,6 @@ users:
password: Password123! password: Password123!
flags: flags:
require_email_verification: false require_email_verification: true
disable_signup_without_invite: true
disable_user_create_org: true

View file

@ -4,6 +4,21 @@ http:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true permanent: true
cors:
headers:
accessControlAllowMethods:
- GET
- PUT
- POST
- DELETE
- PATCH
accessControlAllowHeaders:
- Content-Type
- X-CSRF-Token
accessControlAllowOriginList:
- https://{{.DashboardDomain}}
accessControlAllowCredentials: false
routers: routers:
# HTTP to HTTPS redirect router # HTTP to HTTPS redirect router
@ -14,6 +29,7 @@ http:
- web - web
middlewares: middlewares:
- redirect-to-https - redirect-to-https
- cors
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
@ -21,6 +37,8 @@ http:
service: next-service service: next-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@ -30,6 +48,8 @@ http:
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@ -39,6 +59,8 @@ http:
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt

View file

@ -20,23 +20,30 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() { export function createApiServer() {
const apiServer = express(); const apiServer = express();
// Middleware setup
apiServer.set("trust proxy", 1); apiServer.set("trust proxy", 1);
if (dev) {
apiServer.use(
cors({
origin: `http://localhost:${config.getRawConfig().server.next_port}`,
credentials: true
})
);
} else {
const corsOptions = {
origin: config.getRawConfig().app.dashboard_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
};
apiServer.use(cors(corsOptions)); const corsConfig = config.getRawConfig().server.cors;
const options = {
...(corsConfig?.origins
? { origin: corsConfig.origins }
: {
origin: (origin: any, callback: any) => {
callback(null, true);
}
}),
...(corsConfig?.methods && { methods: corsConfig.methods }),
...(corsConfig?.allowed_headers && {
allowedHeaders: corsConfig.allowed_headers
}),
credentials: !(corsConfig?.credentials === false)
};
logger.debug("Using CORS options", options);
apiServer.use(cors(options));
if (!dev) {
apiServer.use(helmet()); apiServer.use(helmet());
apiServer.use(csrfProtectionMiddleware); apiServer.use(csrfProtectionMiddleware);
} }
@ -47,7 +54,8 @@ export function createApiServer() {
if (!dev) { if (!dev) {
apiServer.use( apiServer.use(
rateLimitMiddleware({ rateLimitMiddleware({
windowMin: config.getRawConfig().rate_limits.global.window_minutes, windowMin:
config.getRawConfig().rate_limits.global.window_minutes,
max: config.getRawConfig().rate_limits.global.max_requests, max: config.getRawConfig().rate_limits.global.max_requests,
type: "IP_AND_PATH" type: "IP_AND_PATH"
}) })

View file

@ -1,6 +1,6 @@
import { import {
encodeBase32LowerCaseNoPadding, encodeBase32LowerCaseNoPadding,
encodeHexLowerCase, encodeHexLowerCase
} from "@oslojs/encoding"; } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema"; import { Session, sessions, User, users } from "@server/db/schema";
@ -9,8 +9,10 @@ import { eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
import logger from "@server/logger";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain(); export const COOKIE_DOMAIN = "." + config.getBaseDomain();
@ -24,25 +26,25 @@ export function generateSessionToken(): string {
export async function createSession( export async function createSession(
token: string, token: string,
userId: string, userId: string
): Promise<Session> { ): Promise<Session> {
const sessionId = encodeHexLowerCase( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)), sha256(new TextEncoder().encode(token))
); );
const session: Session = { const session: Session = {
sessionId: sessionId, sessionId: sessionId,
userId, userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
}; };
await db.insert(sessions).values(session); await db.insert(sessions).values(session);
return session; return session;
} }
export async function validateSessionToken( export async function validateSessionToken(
token: string, token: string
): Promise<SessionValidationResult> { ): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)), sha256(new TextEncoder().encode(token))
); );
const result = await db const result = await db
.select({ user: users, session: sessions }) .select({ user: users, session: sessions })
@ -61,12 +63,12 @@ export async function validateSessionToken(
} }
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date( session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES, Date.now() + SESSION_COOKIE_EXPIRES
).getTime(); ).getTime();
await db await db
.update(sessions) .update(sessions)
.set({ .set({
expiresAt: session.expiresAt, expiresAt: session.expiresAt
}) })
.where(eq(sessions.sessionId, session.sessionId)); .where(eq(sessions.sessionId, session.sessionId));
} }
@ -81,26 +83,38 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId)); await db.delete(sessions).where(eq(sessions.userId, userId));
} }
export function serializeSessionCookie(token: string): string { export function serializeSessionCookie(
if (SECURE_COOKIES) { token: string,
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; isSecure: boolean
): string {
if (isSecure) {
logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} else { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
} }
} }
export function createBlankSessionTokenCookie(): string { export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (SECURE_COOKIES) { if (isSecure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
} else { } else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
} }
} }
const random: RandomReader = { const random: RandomReader = {
read(bytes: Uint8Array): void { read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes); crypto.getRandomValues(bytes);
}, }
}; };
export function generateId(length: number): string { export function generateId(length: number): string {

View file

@ -38,7 +38,13 @@ const environmentSchema = z.object({
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string(), resource_session_cookie_name: z.string(),
resource_access_token_param: z.string() resource_access_token_param: z.string(),
cors: z.object({
origins: z.array(z.string()).optional(),
methods: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(),
credentials: z.boolean().optional(),
}).optional()
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),

View file

@ -120,7 +120,8 @@ export async function login(
const token = generateSessionToken(); const token = generateSessionToken();
await createSession(token, existingUser.userId); await createSession(token, existingUser.userId);
const cookie = serializeSessionCookie(token); const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);

View file

@ -27,7 +27,8 @@ export async function logout(
try { try {
await invalidateSession(sessionId); await invalidateSession(sessionId);
res.setHeader("Set-Cookie", createBlankSessionTokenCookie()); const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, { return response<null>(res, {
data: null, data: null,

View file

@ -158,7 +158,8 @@ export async function signup(
const token = generateSessionToken(); const token = generateSessionToken();
await createSession(token, userId); await createSession(token, userId);
const cookie = serializeSessionCookie(token); const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) { if (config.getRawConfig().flags?.require_email_verification) {