diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/lib/secure-cookies.ts b/apps/dokploy/lib/secure-cookies.ts new file mode 100644 index 000000000..ab11b52ce --- /dev/null +++ b/apps/dokploy/lib/secure-cookies.ts @@ -0,0 +1,25 @@ +export const isHttpsRequest = ( + forwardedProto: string | string[] | undefined, +): boolean => { + if (!forwardedProto) return false; + const value = Array.isArray(forwardedProto) + ? forwardedProto[0] + : forwardedProto; + // take the first value in case a proxy chain sent a list + return value?.split(",")[0]?.trim().toLowerCase() === "https"; +}; + +const hasSecureAttribute = (cookie: string): boolean => + /;\s*secure(?:\s*;|\s*$)/i.test(cookie); + +const withSecure = (cookie: string): string => + hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`; + +// Add Secure to a Set-Cookie header value (string or string[]). +export const secureSetCookie = ( + value: string | number | readonly string[], +): string | number | string[] => { + if (typeof value === "number") return value; + if (Array.isArray(value)) return value.map(withSecure); + return withSecure(value as string); +}; diff --git a/apps/dokploy/pages/api/auth/[...all].ts b/apps/dokploy/pages/api/auth/[...all].ts index 48aa03706..7edbb9847 100644 --- a/apps/dokploy/pages/api/auth/[...all].ts +++ b/apps/dokploy/pages/api/auth/[...all].ts @@ -1,7 +1,30 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import { auth } from "@dokploy/server/index"; import { toNodeHandler } from "better-auth/node"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; // Disallow body parsing, we will parse it manually export const config = { api: { bodyParser: false } }; -export default toNodeHandler(auth.handler); +const handler = toNodeHandler(auth.handler); + +// Mark the session cookie as Secure when the request comes over HTTPS +export default function authHandler(req: IncomingMessage, res: ServerResponse) { + if (isHttpsRequest(req.headers["x-forwarded-proto"])) { + const setHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? setHeader(name, secureSetCookie(value)) + : setHeader(name, value); + + const appendHeader = res.appendHeader?.bind(res); + if (appendHeader) { + res.appendHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? appendHeader(name, secureSetCookie(value) as string | string[]) + : appendHeader(name, value); + } + } + + return handler(req, res); +}