fix: disable add passkey in insecure contexts (#943)
This commit is contained in:
parent
4bba2c2493
commit
c733d52878
5 changed files with 111 additions and 6 deletions
36
app/client/functions/__tests__/is-secure-context.test.ts
Normal file
36
app/client/functions/__tests__/is-secure-context.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
51
app/client/functions/is-secure-context.ts
Normal file
51
app/client/functions/is-secure-context.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
</p>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add passkey
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button onClick={() => setAddDialogOpen(true)} disabled={!isSecureContext}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add passkey
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: isSecureContext })}>
|
||||
<p>Passkeys can only be added over HTTPS or from localhost.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
|
||||
|
|
@ -233,7 +247,7 @@ export function PasskeysSection() {
|
|||
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={addPasskeyMutation.isPending}>
|
||||
<Button type="submit" loading={addPasskeyMutation.isPending} disabled={!isSecureContext}>
|
||||
<Fingerprint className="h-4 w-4 mr-2" />
|
||||
Add passkey
|
||||
</Button>
|
||||
|
|
|
|||
5
bun.lock
5
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue