fix: disable add passkey in insecure contexts (#943)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
This commit is contained in:
parent
4bba2c2493
commit
68002b0308
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";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
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 { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
import { useTimeFormat } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
|
|
@ -38,6 +40,7 @@ type PasskeyEntry = {
|
||||||
|
|
||||||
export function PasskeysSection() {
|
export function PasskeysSection() {
|
||||||
const { formatDateTime } = useTimeFormat();
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
const isSecureContext = getIsSecureContext();
|
||||||
const { data: passkeys, isPending } = useQuery({
|
const { data: passkeys, isPending } = useQuery({
|
||||||
queryKey: ["passkeys"],
|
queryKey: ["passkeys"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|
@ -111,6 +114,8 @@ export function PasskeysSection() {
|
||||||
|
|
||||||
const handleAddPasskey = (e: React.ChangeEvent) => {
|
const handleAddPasskey = (e: React.ChangeEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!isSecureContext) return;
|
||||||
|
|
||||||
const name = newPasskeyName.trim() || undefined;
|
const name = newPasskeyName.trim() || undefined;
|
||||||
addPasskeyMutation.mutate(name);
|
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
|
Passkeys use your device's biometrics or screen lock instead of a password. They are
|
||||||
phishing-resistant and cannot be reused across sites.
|
phishing-resistant and cannot be reused across sites.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setAddDialogOpen(true)}>
|
<Tooltip>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<TooltipTrigger asChild>
|
||||||
Add passkey
|
<span className="inline-flex">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
|
<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)}>
|
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={addPasskeyMutation.isPending}>
|
<Button type="submit" loading={addPasskeyMutation.isPending} disabled={!isSecureContext}>
|
||||||
<Fingerprint className="h-4 w-4 mr-2" />
|
<Fingerprint className="h-4 w-4 mr-2" />
|
||||||
Add passkey
|
Add passkey
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -55,6 +55,7 @@
|
||||||
"hono-rate-limiter": "^0.5.3",
|
"hono-rate-limiter": "^0.5.3",
|
||||||
"http-errors-enhanced": "^4.0.2",
|
"http-errors-enhanced": "^4.0.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"ipaddr.js": "^2.4.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
@ -1683,7 +1684,7 @@
|
||||||
|
|
||||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"recharts/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
"recharts/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
"hono-rate-limiter": "^0.5.3",
|
"hono-rate-limiter": "^0.5.3",
|
||||||
"http-errors-enhanced": "^4.0.2",
|
"http-errors-enhanced": "^4.0.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"ipaddr.js": "^2.4.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue