Compare commits

..
Author SHA1 Message Date
ajnart f79095d40e fix(auth): patch critical Auth.js vulnerabilities 2026-09-06 10:03:21 +02:00
15 changed files with 98 additions and 141 deletions

No files matched your search

-2
View File
@@ -63,8 +63,6 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup.
# Public Homarr origin used by MCP OAuth metadata and redirects behind proxies.
# BASE_URL=https://homarr.example.com
HOMARR_WEBSITE_URL=https://homarr.dev
# PocketBase API used by Homarr and by the standalone documentation site.
# Defaults to HOMARR_WEBSITE_URL.
@@ -30,7 +30,6 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `BASE_URL` | Public Homarr origin used by MCP OAuth metadata and redirects. Set this when a reverse proxy does not preserve the public host and protocol. | HTTP(S) origin without a path | - |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
+1 -1
View File
@@ -40,7 +40,7 @@ services:
volumes:
- ./homarr/appdata:/appdata
environment:
- BASE_URL=https://your.internal.dns.address.here.com
- BASE_URL=your.internal.dns.address.here.com
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
networks:
- proxy
-4
View File
@@ -54,10 +54,6 @@ The **AI / MCP** tab on Homarr's API page shows the current endpoint, configurat
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
-9
View File
@@ -65,15 +65,6 @@ const nextConfig: NextConfig = {
},
],
},
async rewrites() {
return [
{ source: "/.well-known/:path*", destination: "/well-known/:path*" },
{
source: "/api/mcp/:transport/.well-known/:path*",
destination: "/well-known/:path*/api/mcp/:transport",
},
];
},
// skipcq: JS-0116
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
async headers() {
@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
resource: `${baseUrl}/api/mcp`,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -22,10 +22,7 @@ vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({
extractBaseUrlFromHeaders: () => "http://localhost",
removeTrailingSlash: (value: string) => value.replace(/\/+$/u, ""),
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
@@ -45,7 +42,6 @@ vi.mock("@homarr/custom-widgets/core", () => ({
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/env", () => ({ env: { BASE_URL: undefined } }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [
-8
View File
@@ -1,8 +0,0 @@
import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common";
import { env } from "~/env";
type RequestHeaders = Parameters<typeof extractBaseUrlFromHeaders>[0];
export const getMcpBaseUrl = (headers: RequestHeaders) =>
removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers));
+8 -22
View File
@@ -7,7 +7,7 @@ import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { removeTrailingSlash } from "@homarr/common";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -24,7 +24,6 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { getMcpBaseUrl } from "./_base-url";
import { extractMcpTools } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
@@ -247,12 +246,6 @@ function jsonErrorResponse(status: number, body: Record<string, string>, extraHe
});
}
const getOAuthChallenge = (req: NextRequest) => {
const baseUrl = getMcpBaseUrl(req.headers);
const resourcePath = removeTrailingSlash(new URL(req.url).pathname);
return `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`;
};
export const handleMcpRequest = async (req: NextRequest) => {
const pathname = new URL(req.url).pathname;
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
@@ -276,37 +269,30 @@ export const handleMcpRequest = async (req: NextRequest) => {
if (!apiKeyValue) {
recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse(
401,
{
error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
},
{ "WWW-Authenticate": getOAuthChallenge(req) },
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
);
}
if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(
401,
{ error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." },
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
}
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(
401,
{
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
},
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
return jsonErrorResponse(401, {
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
});
}
clearAuthFailures(ipAddress);
@@ -1,8 +1,8 @@
import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getMcpBaseUrl } from "../../_base-url";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) {
@@ -83,9 +83,8 @@ export async function GET(req: NextRequest) {
);
}
const baseUrl = getMcpBaseUrl(req.headers);
const allowedResources = new Set([`${baseUrl}/api/mcp`, `${baseUrl}/api/mcp/mcp`]);
if (resource && !allowedResources.has(resource)) {
const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`;
if (resource && resource !== expectedResource) {
return Response.json(
{
error: "invalid_target",
@@ -153,7 +152,7 @@ export async function GET(req: NextRequest) {
);
}
const loginUrl = new URL("/auth/login", baseUrl);
const loginUrl = new URL("/auth/login", url.origin);
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
return Response.redirect(loginUrl.toString());
}
@@ -1,51 +0,0 @@
import type { NextRequest } from "next/server";
import { getMcpBaseUrl } from "~/app/api/mcp/_base-url";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) {
const [kind, ...rest] = (await props.params).path;
const baseUrl = getMcpBaseUrl(req.headers);
const identifierPath = rest.join("/");
if (kind === "oauth-authorization-server") {
const issuer = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
return Response.json(
{
issuer,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
}
if (kind === "oauth-protected-resource") {
const resource = identifierPath ? `${baseUrl}/${identifierPath}` : `${baseUrl}/api/mcp`;
return Response.json(
{
resource,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
return Response.json({ error: "not_found" }, { status: 404, headers: corsHeaders });
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
-7
View File
@@ -16,15 +16,8 @@ const publicHttpUrl = (variableName: string) =>
}
});
const publicHttpOrigin = (variableName: string) =>
publicHttpUrl(variableName).refine(
(value) => value === new URL(value).origin,
`${variableName} must be an HTTP(S) origin without a path`,
);
export const env = createEnv({
server: {
BASE_URL: publicHttpOrigin("BASE_URL").optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
+23 -23
View File
@@ -25,11 +25,11 @@ catalogs:
specifier: 0.14.9
version: 0.14.9
'@auth/core':
specifier: ^0.41.2
version: 0.41.2
specifier: ^0.41.3
version: 0.41.3
'@auth/drizzle-adapter':
specifier: ^1.11.2
version: 1.11.2
specifier: ^1.11.3
version: 1.11.3
'@babel/parser':
specifier: ^7.29.7
version: 7.29.7
@@ -520,8 +520,8 @@ catalogs:
specifier: 16.3.1
version: 16.3.1
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31
specifier: 5.0.0-beta.32
version: 5.0.0-beta.32
next-intl:
specifier: 4.13.1
version: 4.13.1
@@ -1587,10 +1587,10 @@ importers:
dependencies:
'@auth/core':
specifier: 'catalog:'
version: 0.41.2
version: 0.41.3
'@auth/drizzle-adapter':
specifier: 'catalog:'
version: 1.11.2
version: 1.11.3
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -1620,7 +1620,7 @@ importers:
version: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
next-auth:
specifier: 'catalog:'
version: 5.0.0-beta.31(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
version: 5.0.0-beta.32(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
react:
specifier: 'catalog:'
version: 19.2.7
@@ -1989,7 +1989,7 @@ importers:
dependencies:
'@auth/core':
specifier: 'catalog:'
version: 0.41.2
version: 0.41.3
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -3346,12 +3346,12 @@ packages:
react:
optional: true
'@auth/core@0.41.2':
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
'@auth/core@0.41.3':
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^7.0.7
nodemailer: ^7.0.7 || ^8.0.5
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
@@ -3360,8 +3360,8 @@ packages:
nodemailer:
optional: true
'@auth/drizzle-adapter@1.11.2':
resolution: {integrity: sha512-VOuj7REI8jfJjpSbsYwDM/Zrn55T6lS9Yc+29V+EXMcel8eqG7x+7LodNfd1WHjakfkLYi+qsbYUHB3E6aDA4w==}
'@auth/drizzle-adapter@1.11.3':
resolution: {integrity: sha512-TxqVasPVuf7LDAT1Yuu6bftpuet8o0tjdXW+mXpnXWdSRPQsomdzMoz9RsXkFN/JfdK+/DwdgOEmOTv1gbiFNw==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
@@ -13685,13 +13685,13 @@ packages:
neverpanic@0.0.8:
resolution: {integrity: sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw==}
next-auth@5.0.0-beta.31:
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
next-auth@5.0.0-beta.32:
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
nodemailer: ^7.0.7
nodemailer: ^7.0.7 || ^8.0.5
react: ^18.2.0 || ^19.0.0
peerDependenciesMeta:
'@simplewebauthn/browser':
@@ -17439,7 +17439,7 @@ snapshots:
'@types/react': 19.2.17
react: 19.2.7
'@auth/core@0.41.2':
'@auth/core@0.41.3':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
@@ -17447,9 +17447,9 @@ snapshots:
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@auth/drizzle-adapter@1.11.2':
'@auth/drizzle-adapter@1.11.3':
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.3
transitivePeerDependencies:
- '@simplewebauthn/browser'
- '@simplewebauthn/server'
@@ -30001,9 +30001,9 @@ snapshots:
neverpanic@0.0.8: {}
next-auth@5.0.0-beta.31(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7):
next-auth@5.0.0-beta.32(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7):
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.3
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7
+3 -3
View File
@@ -14,8 +14,8 @@ catalog:
"@codemirror/state": ^6.6.0
"@codemirror/theme-one-dark": ^6.1.3
"@codemirror/view": ^6.43.0
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
"@auth/core": ^0.41.3
"@auth/drizzle-adapter": ^1.11.3
"@ctrl/deluge": ^8.0.0
"@ctrl/qbittorrent": ^9.14.0
"@ctrl/transmission": ^7.4.0
@@ -174,7 +174,7 @@ catalog:
maria2: ^0.4.1
mysql2: 3.22.6
next: 16.3.1
next-auth: 5.0.0-beta.31
next-auth: 5.0.0-beta.32
next-intl: 4.13.1
next-themes: ^0.4.6
node-loader: ^2.1.0