From 68002b03089ebd45ef2647d4f5d7c343493da436 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:06:48 +0200 Subject: [PATCH] fix: disable add passkey in insecure contexts (#943) --- .../__tests__/is-secure-context.test.ts | 36 +++++++++++++ app/client/functions/is-secure-context.ts | 51 +++++++++++++++++++ .../settings/components/passkeys-section.tsx | 24 +++++++-- bun.lock | 5 +- package.json | 1 + 5 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 app/client/functions/__tests__/is-secure-context.test.ts create mode 100644 app/client/functions/is-secure-context.ts 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() { -