From f225b8194ed590316dd3fa3a6ac4b77c7ac3847f Mon Sep 17 00:00:00 2001
From: Nicolas Meienberger
Date: Thu, 4 Jun 2026 20:59:47 +0200
Subject: [PATCH] fix: disable add passkey in insecure contexts
---
.../__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() {
-