diff --git a/app/client/functions/__tests__/is-secure-context.test.ts b/app/client/functions/__tests__/is-secure-context.test.ts new file mode 100644 index 00000000..f02f62cf --- /dev/null +++ b/app/client/functions/__tests__/is-secure-context.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { getIsSecureContextForRequest, isSecureContextOrigin } from "../is-secure-context"; + +describe("isSecureContextOrigin", () => { + test("treats HTTPS and loopback HTTP origins as secure contexts", () => { + expect(isSecureContextOrigin("https://example.com")).toBe(true); + expect(isSecureContextOrigin("http://localhost:3000")).toBe(true); + expect(isSecureContextOrigin("http://app.localhost")).toBe(true); + expect(isSecureContextOrigin("http://127.0.0.1:3000")).toBe(true); + expect(isSecureContextOrigin("http://[::1]:3000")).toBe(true); + }); + + test("treats non-local HTTP origins as insecure contexts", () => { + expect(isSecureContextOrigin("http://example.com")).toBe(false); + expect(isSecureContextOrigin("http://127.example.com")).toBe(false); + }); +}); + +describe("getIsSecureContextForRequest", () => { + test("uses forwarded protocol and host when present", () => { + const request = new Request("http://internal.local/settings", { + headers: { + host: "internal.local", + "x-forwarded-host": "zerobyte.example.com", + "x-forwarded-proto": "https", + }, + }); + + expect(getIsSecureContextForRequest(request)).toBe(true); + }); + + test("uses the request URL when forwarded headers are absent", () => { + expect(getIsSecureContextForRequest(new Request("http://zerobyte.example.com/settings"))).toBe(false); + expect(getIsSecureContextForRequest(new Request("https://zerobyte.example.com/settings"))).toBe(true); + }); +}); diff --git a/app/client/functions/is-secure-context.ts b/app/client/functions/is-secure-context.ts new file mode 100644 index 00000000..591cc47e --- /dev/null +++ b/app/client/functions/is-secure-context.ts @@ -0,0 +1,51 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; +import ipaddr from "ipaddr.js"; + +const getFirstHeaderValue = (value: string | null) => value?.split(",")[0]?.trim(); + +const isLoopbackIp = (hostname: string) => { + try { + return ipaddr.parse(hostname).range() === "loopback"; + } catch { + return false; + } +}; + +const getRequestOrigin = (request: Request) => { + const requestUrl = new URL(request.url); + const forwardedProto = getFirstHeaderValue(request.headers.get("x-forwarded-proto")); + const forwardedHost = getFirstHeaderValue(request.headers.get("x-forwarded-host")); + const host = forwardedHost || getFirstHeaderValue(request.headers.get("host")); + + if (!host) { + return requestUrl.origin; + } + + const protocol = (forwardedProto || requestUrl.protocol).replace(/:$/, ""); + return `${protocol}://${host}`; +}; + +export const isSecureContextOrigin = (origin: string) => { + const url = new URL(origin); + + if (url.protocol === "https:") { + return true; + } + + const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); + + return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackIp(hostname); +}; + +export const getIsSecureContextForRequest = (request: Request) => isSecureContextOrigin(getRequestOrigin(request)); + +export const getIsSecureContext = createIsomorphicFn() + .server(() => { + try { + return getIsSecureContextForRequest(getRequest()); + } catch { + return true; + } + }) + .client(() => window.isSecureContext ?? true); diff --git a/app/client/modules/settings/components/passkeys-section.tsx b/app/client/modules/settings/components/passkeys-section.tsx index bffbaa2c..67080c7d 100644 --- a/app/client/modules/settings/components/passkeys-section.tsx +++ b/app/client/modules/settings/components/passkeys-section.tsx @@ -24,6 +24,8 @@ import { } from "~/client/components/ui/dialog"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { getIsSecureContext } from "~/client/functions/is-secure-context"; import { authClient } from "~/client/lib/auth-client"; import { logger } from "~/client/lib/logger"; import { useTimeFormat } from "~/client/lib/datetime"; @@ -38,6 +40,7 @@ type PasskeyEntry = { export function PasskeysSection() { const { formatDateTime } = useTimeFormat(); + const isSecureContext = getIsSecureContext(); const { data: passkeys, isPending } = useQuery({ queryKey: ["passkeys"], queryFn: async () => { @@ -111,6 +114,8 @@ export function PasskeysSection() { const handleAddPasskey = (e: React.ChangeEvent) => { e.preventDefault(); + if (!isSecureContext) return; + const name = newPasskeyName.trim() || undefined; addPasskeyMutation.mutate(name); }; @@ -149,10 +154,19 @@ export function PasskeysSection() { Passkeys use your device's biometrics or screen lock instead of a password. They are phishing-resistant and cannot be reused across sites.

- + + + + + + + +

Passkeys can only be added over HTTPS or from localhost.

+
+

Loading passkeys...

@@ -233,7 +247,7 @@ export function PasskeysSection() { - diff --git a/bun.lock b/bun.lock index a2c30f7e..16c4a355 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "hono-rate-limiter": "^0.5.3", "http-errors-enhanced": "^4.0.2", "input-otp": "^1.4.2", + "ipaddr.js": "^2.4.0", "lucide-react": "^1.17.0", "qrcode.react": "^4.2.0", "react": "^19.2.6", @@ -1683,7 +1684,7 @@ "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -2665,6 +2666,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recharts/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], diff --git a/package.json b/package.json index 1bad1272..eef8c58d 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "hono-rate-limiter": "^0.5.3", "http-errors-enhanced": "^4.0.2", "input-otp": "^1.4.2", + "ipaddr.js": "^2.4.0", "lucide-react": "^1.17.0", "qrcode.react": "^4.2.0", "react": "^19.2.6",