diff --git a/README.md b/README.md index 1648a0887..633bc50f8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- A modern, fully static, fast, secure fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery. + A modern, fast, secure fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery.

@@ -31,7 +31,7 @@ With features like quick search, bookmarks, weather support, a wide range of integrations and widgets, an elegant and modern design, and a focus on performance, Homepage is your ideal start to the day and a handy companion throughout it. -- **Fast** - The site is statically generated at build time for instant load times. +- **Fast** - Cached dashboard data and optimized assets keep page loads quick. - **Secure** - All API requests to backend services are proxied, keeping your API keys hidden. Constantly reviewed for security by the community. - **For Everyone** - Images built for AMD64, ARM64. - **Full i18n** - Support for over 40 languages. diff --git a/docs/configs/settings.md b/docs/configs/settings.md index 254209332..813db8f02 100644 --- a/docs/configs/settings.md +++ b/docs/configs/settings.md @@ -3,7 +3,7 @@ title: Settings description: Service Configuration --- -The `settings.yaml` file allows you to define application level options. For changes made to this file to take effect, you will need to regenerate the static HTML, this can be done by clicking the refresh icon in the bottom right of the page. +The `settings.yaml` file allows you to define application level options. After making changes, click the refresh icon in the bottom right of the page to clear the cached dashboard data and reload the page. ## Title @@ -33,7 +33,7 @@ startUrl: https://custom.url !!! warning "Heads Up!" - You will need to restart the container any time you add new images, this is a limitation of the Next.js static site server. + You will need to restart the container any time you add new local images so the server can discover them. !!! warning "Heads Up!" diff --git a/docs/index.md b/docs/index.md index 31a84e8cd..7014efcf9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ --- title: Home -description: A modern, fully static, fast, secure, fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. +description: A modern, fast, secure, fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. icon: material/home hide: - navigation @@ -18,7 +18,7 @@ hide: homepage -

A modern, fully static, fast, secure fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery.

+

A modern, fast, secure fully proxied, highly customizable application dashboard with integrations for over 100 services and translations into multiple languages. Easily configured via YAML files or through docker label discovery.

diff --git a/docs/installation/source.md b/docs/installation/source.md index fd0275a75..3fa169c89 100644 --- a/docs/installation/source.md +++ b/docs/installation/source.md @@ -30,6 +30,6 @@ Finally, run the server: HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start ``` -When updating homepage versions you will need to re-build the static files i.e. repeat the process above. +When updating Homepage versions, rebuild the production bundle by repeating the steps above. See [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable. diff --git a/src/__tests__/pages/api/auth-boundary.test.js b/src/__tests__/pages/api/auth-boundary.test.js new file mode 100644 index 000000000..310874176 --- /dev/null +++ b/src/__tests__/pages/api/auth-boundary.test.js @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +vi.mock("next-auth/jwt", () => ({ getToken: vi.fn(async () => null) })); +vi.mock("utils/logger", () => ({ default: () => ({ error: vi.fn(), warn: vi.fn(), debug: vi.fn(), info: vi.fn() }) })); + +const allRoutes = Object.entries(import.meta.glob("/src/pages/api/**/*.js")); +const routes = allRoutes.filter(([file]) => !/\/(auth|mcp)\//.test(file) && !file.endsWith("/healthcheck.js")); + +describe("API authentication without middleware", () => { + beforeEach(() => vi.stubEnv("HOMEPAGE_AUTH_ENABLED", "true")); + afterEach(() => vi.unstubAllEnvs()); + + it.each(routes)("rejects anonymous access to %s at its exported handler", async (_file, load) => { + const { default: handler } = await load(); + const req = { query: {}, headers: { "x-prerender-revalidate": "public-image-key" }, cookies: {} }; + const res = createMockRes(); + await handler(req, res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + expect(res.headers["Cache-Control"]).toBe("private, no-store"); + }); + + it("keeps sign-in CSS public while protecting custom JavaScript", async () => { + const { default: handler } = await import("pages/api/config/[path]"); + const css = createMockRes(); + await handler({ query: { path: "custom.css" } }, css); + expect(css.statusCode).toBe(200); + const js = createMockRes(); + await handler({ query: { path: "custom.js" }, headers: {}, cookies: {} }, js); + expect(js.statusCode).toBe(401); + }); +}); + +describe("API host validation without middleware", () => { + beforeEach(() => { + vi.stubEnv("HOMEPAGE_ALLOWED_HOSTS", ""); + vi.stubEnv("PORT", "3000"); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each(allRoutes)("rejects disallowed hosts at %s, including public routes", async (_file, load) => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { default: handler } = await load(); + const req = { + query: { path: "custom.css", nextauth: ["session"] }, + method: "POST", + headers: { host: "evil.example", "x-prerender-revalidate": "public-image-key" }, + cookies: {}, + }; + const res = createMockRes(); + await handler(req, res); + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: "Host validation failed. See logs for more details." }); + }); +}); diff --git a/src/__tests__/pages/api/revalidate.test.js b/src/__tests__/pages/api/revalidate.test.js index c6428f5da..a2d5a95c3 100644 --- a/src/__tests__/pages/api/revalidate.test.js +++ b/src/__tests__/pages/api/revalidate.test.js @@ -3,27 +3,20 @@ import { describe, expect, it, vi } from "vitest"; import createMockRes from "test-utils/create-mock-res"; import handler from "pages/api/revalidate"; +import { invalidateDashboardData } from "utils/config/dashboard"; + +vi.mock("utils/config/dashboard", () => ({ invalidateDashboardData: vi.fn() })); describe("pages/api/revalidate", () => { - it("revalidates and returns {revalidated:true}", async () => { + it("invalidates the dashboard snapshot without regenerating a static page", async () => { const req = {}; const res = createMockRes(); res.revalidate = vi.fn().mockResolvedValueOnce(undefined); await handler(req, res); - expect(res.revalidate).toHaveBeenCalledWith("/"); + expect(invalidateDashboardData).toHaveBeenCalledOnce(); + expect(res.revalidate).not.toHaveBeenCalled(); expect(res.body).toEqual({ revalidated: true }); }); - - it("returns 500 when revalidate throws", async () => { - const req = {}; - const res = createMockRes(); - res.revalidate = vi.fn().mockRejectedValueOnce(new Error("nope")); - - await handler(req, res); - - expect(res.statusCode).toBe(500); - expect(res.body).toBe("Error revalidating"); - }); }); diff --git a/src/__tests__/pages/api/theme.test.js b/src/__tests__/pages/api/theme.test.js index 1d2d7642d..e9e0ea685 100644 --- a/src/__tests__/pages/api/theme.test.js +++ b/src/__tests__/pages/api/theme.test.js @@ -19,22 +19,22 @@ describe("pages/api/theme", () => { vi.clearAllMocks(); }); - it("returns defaults when settings are missing", () => { + it("returns defaults when settings are missing", async () => { getSettings.mockReturnValueOnce({}); const res = createMockRes(); - handler({ res }); + await handler({}, res); expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml"); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ color: "slate", theme: "dark" }); }); - it("returns configured color + theme when present", () => { + it("returns configured color + theme when present", async () => { getSettings.mockReturnValueOnce({ color: "red", theme: "light" }); const res = createMockRes(); - handler({ res }); + await handler({}, res); expect(res.body).toEqual({ color: "red", theme: "light" }); }); diff --git a/src/__tests__/pages/index.test.jsx b/src/__tests__/pages/index.test.jsx index c543161c7..9039c6bba 100644 --- a/src/__tests__/pages/index.test.jsx +++ b/src/__tests__/pages/index.test.jsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invalidateDashboardData } from "utils/config/dashboard"; import { ColorContext } from "utils/contexts/color"; import { SettingsContext } from "utils/contexts/settings"; import { TabContext } from "utils/contexts/tab"; @@ -170,8 +171,9 @@ vi.mock("components/toggles/revalidate", () => ({ default: () => null, })); -describe("pages/index getStaticProps", () => { +describe("pages/index getServerSideProps", () => { beforeEach(() => { + invalidateDashboardData(); vi.clearAllMocks(); state.throwIn = null; state.validateData = []; @@ -190,8 +192,8 @@ describe("pages/index getStaticProps", () => { it("returns initial settings and api fallbacks for swr", async () => { getSettings.mockReturnValueOnce({ providers: { x: 1 }, language: "en", title: "Homepage" }); - const { getStaticProps } = await import("pages/index.jsx"); - const result = await getStaticProps(); + const { getServerSideProps } = await import("pages/index.jsx"); + const result = await getServerSideProps({}); expect(result.props.initialSettings).toEqual({ language: "en", title: "Homepage" }); expect(result.props.fallback["/api/services"]).toEqual([{ name: "svc" }]); @@ -204,8 +206,8 @@ describe("pages/index getStaticProps", () => { it("normalizes legacy language codes before requesting translations", async () => { getSettings.mockReturnValueOnce({ providers: {}, language: "zh-CN" }); - const { getStaticProps } = await import("pages/index.jsx"); - await getStaticProps(); + const { getServerSideProps } = await import("pages/index.jsx"); + await getServerSideProps({}); expect(serverSideTranslations).toHaveBeenCalledWith("zh-Hans"); }); @@ -214,8 +216,8 @@ describe("pages/index getStaticProps", () => { getSettings.mockReturnValueOnce({ providers: {}, language: "de" }); state.throwIn = "services"; - const { getStaticProps } = await import("pages/index.jsx"); - const result = await getStaticProps(); + const { getServerSideProps } = await import("pages/index.jsx"); + const result = await getServerSideProps({}); expect(result.props.initialSettings).toEqual({}); expect(result.props.fallback["/api/services"]).toEqual([]); diff --git a/src/middleware.js b/src/middleware.js index 884d8893f..29228a38a 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,6 +1,7 @@ import { getToken } from "next-auth/jwt"; import { NextResponse } from "next/server"; +import { isAllowedHost, secureCookie } from "utils/auth"; import { isAuthEnabled } from "utils/env"; const authEnabled = isAuthEnabled(); @@ -17,18 +18,7 @@ function withPrivateCache(res) { } export async function middleware(req) { - // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set - const host = req.headers.get("host"); - const port = process.env.PORT || 3000; - let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]; - const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === "*"; - if (process.env.HOMEPAGE_ALLOWED_HOSTS) { - allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(",")); - } - if (!allowAll && (!host || !allowedHosts.includes(host))) { - console.error( - `Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`, - ); + if (!isAllowedHost(req.headers.get("host"))) { return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 }); } @@ -40,7 +30,7 @@ export async function middleware(req) { return withPrivateCache(NextResponse.next()); } - const token = await getToken({ req, secret: authSecret }); + const token = await getToken({ req, secret: authSecret, secureCookie: secureCookie() }); if (!token) { const signInUrl = new URL("/auth/signin", req.url); // Same-origin by construction, so this cannot be used as an open redirect diff --git a/src/middleware.test.js b/src/middleware.test.js index a8890e6b8..8b7234feb 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -37,6 +37,7 @@ describe("middleware", () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; + delete process.env.HOMEPAGE_ALLOWED_HOSTS; console.error = originalConsoleError; }); @@ -135,6 +136,29 @@ describe("middleware", () => { expect(res.type).toBe("next"); }); + it.each([ + ["NEXTAUTH_URL", "https://homepage.example", true], + ["NEXTAUTH_URL", "HTTPS://homepage.example", true], + ["NEXTAUTH_URL", " https://homepage.example ", true], + ["NEXTAUTH_URL", "HTTP://homepage.example", false], + ["HOMEPAGE_EXTERNAL_URL", "HTTPS://homepage.example", true], + ["HOMEPAGE_EXTERNAL_URL", " https://homepage.example ", true], + ["HOMEPAGE_EXTERNAL_URL", "homepage.example", false], + ])("reads the session cookie NextAuth sets when %s is %j", async (variable, url, secure) => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + delete process.env.NEXTAUTH_URL; + process.env[variable] = url; + + getToken.mockResolvedValueOnce({ sub: "user" }); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); + + expect(getToken).toHaveBeenCalledWith(expect.objectContaining({ secureCookie: secure })); + expect(res.type).toBe("next"); + }); + it("redirects to signin when auth is enabled and no token is present", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_SECRET = "secret"; @@ -147,6 +171,7 @@ describe("middleware", () => { expect(getToken).toHaveBeenCalledWith({ req: expect.objectContaining({ url: "http://localhost:3000/some" }), secret: "secret", + secureCookie: false, }); expect(NextResponse.redirect).toHaveBeenCalled(); expect(res.type).toBe("redirect"); diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 5379f8bbd..c738e35bf 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -3,6 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto"; import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; +import { withAllowedHost } from "utils/auth"; import { applyNextAuthEnv, isAuthEnabled } from "utils/env"; import createLogger from "utils/logger"; @@ -163,7 +164,7 @@ export const authOptions = { const nextAuthHandler = NextAuth(authOptions); -export default async function handler(req, res) { +async function handler(req, res) { // Just pass empty session if auth not enabled if (!authEnabled) { return res.status(200).json({}); @@ -171,3 +172,5 @@ export default async function handler(req, res) { return nextAuthHandler(req, res); } + +export default withAllowedHost(handler); diff --git a/src/pages/api/bookmarks.js b/src/pages/api/bookmarks.js index 63d1e29ed..6a47648cf 100644 --- a/src/pages/api/bookmarks.js +++ b/src/pages/api/bookmarks.js @@ -1,5 +1,8 @@ +import { withAuth } from "utils/auth"; import { bookmarksResponse } from "utils/config/api-response"; -export default async function handler(req, res) { +async function handler(req, res) { res.send(await bookmarksResponse()); } + +export default withAuth(handler); diff --git a/src/pages/api/config/[path].js b/src/pages/api/config/[path].js index b69ddff58..66e096d04 100644 --- a/src/pages/api/config/[path].js +++ b/src/pages/api/config/[path].js @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; +import { withAuth } from "utils/auth"; import { CONF_DIR } from "utils/config/config"; import createLogger from "utils/logger"; @@ -10,7 +11,7 @@ const logger = createLogger("configFileService"); * @param {import("next").NextApiRequest} req * @param {import("next").NextApiResponse} res */ -export default async function handler(req, res) { +async function handler(req, res) { const { path: relativePath } = req.query; // only two supported files, for now @@ -32,3 +33,5 @@ export default async function handler(req, res) { return res.status(500).end("Internal Server Error"); } } + +export default withAuth(handler, { public: (req) => req.query?.path === "custom.css" }); diff --git a/src/pages/api/docker/stats.js b/src/pages/api/docker/stats.js index 8bca4aa2b..493ba8e01 100644 --- a/src/pages/api/docker/stats.js +++ b/src/pages/api/docker/stats.js @@ -1,9 +1,10 @@ +import { withAuth } from "utils/auth"; import { getDockerStats } from "utils/docker/stats"; import createLogger from "utils/logger"; const logger = createLogger("dockerStats"); -export default async function handler(req, res) { +async function handler(req, res) { try { const result = await getDockerStats(req.query.server); @@ -19,3 +20,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/docker/statuses.js b/src/pages/api/docker/statuses.js index 2b3fefc38..90fd19c71 100644 --- a/src/pages/api/docker/statuses.js +++ b/src/pages/api/docker/statuses.js @@ -1,9 +1,10 @@ +import { withAuth } from "utils/auth"; import { getDockerStatuses } from "utils/docker/status"; import createLogger from "utils/logger"; const logger = createLogger("dockerStatuses"); -export default async function handler(req, res) { +async function handler(req, res) { try { const result = await getDockerStatuses(req.query.server); @@ -19,3 +20,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/hash.js b/src/pages/api/hash.js index 33fb4ef55..d4c842d05 100644 --- a/src/pages/api/hash.js +++ b/src/pages/api/hash.js @@ -2,6 +2,7 @@ import { createHash } from "crypto"; import { readFileSync } from "fs"; import { join } from "path"; +import { withAuth } from "utils/auth"; import checkAndCopyConfig, { CONF_DIR } from "utils/config/config"; const configs = [ @@ -20,7 +21,7 @@ function hash(buffer) { return hashSum.digest("hex"); } -export default async function handler(req, res) { +async function handler(req, res) { const hashes = configs.map((config) => { checkAndCopyConfig(config); const configYaml = join(CONF_DIR, config); @@ -36,3 +37,5 @@ export default async function handler(req, res) { hash: combinedHash, }); } + +export default withAuth(handler); diff --git a/src/pages/api/healthcheck.js b/src/pages/api/healthcheck.js index a5ffc66a3..cc4ba8923 100644 --- a/src/pages/api/healthcheck.js +++ b/src/pages/api/healthcheck.js @@ -1,3 +1,7 @@ -export default function handler(req, res) { +import { withAllowedHost } from "utils/auth"; + +function handler(req, res) { res.send("up"); } + +export default withAllowedHost(handler); diff --git a/src/pages/api/kubernetes/stats/[...service].js b/src/pages/api/kubernetes/stats/[...service].js index ab4541834..f8645d8e7 100644 --- a/src/pages/api/kubernetes/stats/[...service].js +++ b/src/pages/api/kubernetes/stats/[...service].js @@ -4,9 +4,11 @@ import { getKubeConfig } from "../../../../utils/config/kubernetes"; import { parseCpu, parseMemory } from "../../../../utils/kubernetes/utils"; import createLogger from "../../../../utils/logger"; +import { withAuth } from "utils/auth"; + const logger = createLogger("kubernetesStatsService"); -export default async function handler(req, res) { +async function handler(req, res) { const APP_LABEL = "app.kubernetes.io/name"; const { service, podSelector } = req.query; @@ -108,3 +110,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/kubernetes/status/[...service].js b/src/pages/api/kubernetes/status/[...service].js index e85589240..4cb8c319b 100644 --- a/src/pages/api/kubernetes/status/[...service].js +++ b/src/pages/api/kubernetes/status/[...service].js @@ -3,9 +3,11 @@ import { CoreV1Api } from "@kubernetes/client-node"; import { getKubeConfig } from "../../../../utils/config/kubernetes"; import createLogger from "../../../../utils/logger"; +import { withAuth } from "utils/auth"; + const logger = createLogger("kubernetesStatusService"); -export default async function handler(req, res) { +async function handler(req, res) { const APP_LABEL = "app.kubernetes.io/name"; const { service, podSelector } = req.query; @@ -68,3 +70,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/mcp/index.js b/src/pages/api/mcp/index.js index 9e52acf25..9f6e10588 100644 --- a/src/pages/api/mcp/index.js +++ b/src/pages/api/mcp/index.js @@ -1,6 +1,7 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "pages/api/auth/[...nextauth]"; +import { withAllowedHost } from "utils/auth"; import { isAuthEnabled } from "utils/env"; import createLogger from "utils/logger"; import { handleMcpRequest, mcpEnabled, mcpTokenAuthorized, mcpTokenConfigError } from "utils/mcp/homepage-mcp"; @@ -10,7 +11,8 @@ async function hasHomepageSession(req, res) { return Boolean(await getServerSession(req, res, authOptions)); } -export default async function handler(req, res) { +async function handler(req, res) { + res.setHeader("Cache-Control", "private, no-store"); if (!mcpEnabled()) { return res.status(404).end("Not Found"); } @@ -38,3 +40,5 @@ export default async function handler(req, res) { return res.status(200).json(response); } + +export default withAllowedHost(handler); diff --git a/src/pages/api/ping.js b/src/pages/api/ping.js index 07a40346e..b6ed0a4bd 100644 --- a/src/pages/api/ping.js +++ b/src/pages/api/ping.js @@ -1,11 +1,12 @@ import { promise as ping } from "ping"; +import { withAuth } from "utils/auth"; import { getServiceItem } from "utils/config/service-helpers"; import createLogger from "utils/logger"; const logger = createLogger("ping"); -export default async function handler(req, res) { +async function handler(req, res) { const { groupName, serviceName } = req.query; const serviceItem = await getServiceItem(groupName, serviceName); if (!serviceItem) { @@ -40,3 +41,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/proxmox/stats/[...service].js b/src/pages/api/proxmox/stats/[...service].js index fefd4fe44..dcc914792 100644 --- a/src/pages/api/proxmox/stats/[...service].js +++ b/src/pages/api/proxmox/stats/[...service].js @@ -1,3 +1,4 @@ +import { withAuth } from "utils/auth"; import { getProxmoxConfig } from "utils/config/proxmox"; import createLogger from "utils/logger"; import { httpProxy } from "utils/proxy/http"; @@ -7,7 +8,7 @@ const VALID_VM_TYPES = new Set(["qemu", "lxc"]); const VALID_NODE = /^[A-Za-z0-9._-]+$/; const VALID_VMID = /^\d+$/; -export default async function handler(req, res) { +async function handler(req, res) { const { service, type: vmType } = req.query; const [node, vmid] = service || []; @@ -103,3 +104,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/releases.js b/src/pages/api/releases.js index 01d0e8a5f..8e3259f21 100644 --- a/src/pages/api/releases.js +++ b/src/pages/api/releases.js @@ -1,9 +1,10 @@ +import { withAuth } from "utils/auth"; import createLogger from "utils/logger"; import { cachedRequest } from "utils/proxy/http"; const logger = createLogger("releases"); -export default async function handler(req, res) { +async function handler(req, res) { const releasesURL = "https://api.github.com/repos/gethomepage/homepage/releases"; try { return res.send(await cachedRequest(releasesURL, 5)); @@ -12,3 +13,5 @@ export default async function handler(req, res) { return res.send([]); } } + +export default withAuth(handler); diff --git a/src/pages/api/revalidate.js b/src/pages/api/revalidate.js index 49d933d43..8a21d3d4f 100644 --- a/src/pages/api/revalidate.js +++ b/src/pages/api/revalidate.js @@ -1,8 +1,9 @@ -export default async function handler(req, res) { - try { - await res.revalidate("/"); - return res.json({ revalidated: true }); - } catch (err) { - return res.status(500).send("Error revalidating"); - } +import { withAuth } from "utils/auth"; +import { invalidateDashboardData } from "utils/config/dashboard"; + +async function handler(req, res) { + invalidateDashboardData(); + return res.json({ revalidated: true }); } + +export default withAuth(handler); diff --git a/src/pages/api/search/searchSuggestion.js b/src/pages/api/search/searchSuggestion.js index 7d291b281..197f7fd56 100644 --- a/src/pages/api/search/searchSuggestion.js +++ b/src/pages/api/search/searchSuggestion.js @@ -1,9 +1,10 @@ import { searchProviders } from "components/widgets/search/search"; +import { withAuth } from "utils/auth"; import { getSettings } from "utils/config/config"; import { widgetsFromConfig } from "utils/config/widget-helpers"; import { cachedRequest } from "utils/proxy/http"; -export default async function handler(req, res) { +async function handler(req, res) { const { query, providerName } = req.query; const provider = Object.values(searchProviders).find(({ name }) => name === providerName); @@ -34,3 +35,5 @@ export default async function handler(req, res) { return res.send(await cachedRequest(`${provider.suggestionUrl}${encodeURIComponent(query)}`, 5, "Mozilla/5.0")); } + +export default withAuth(handler); diff --git a/src/pages/api/services/index.js b/src/pages/api/services/index.js index 46d0a721f..4d255e983 100644 --- a/src/pages/api/services/index.js +++ b/src/pages/api/services/index.js @@ -1,5 +1,8 @@ +import { withAuth } from "utils/auth"; import { servicesResponse } from "utils/config/api-response"; -export default async function handler(req, res) { +async function handler(req, res) { res.send(await servicesResponse()); } + +export default withAuth(handler); diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index 123794854..fdba6e419 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -1,3 +1,4 @@ +import { withAuth } from "utils/auth"; import getServiceWidget from "utils/config/service-helpers"; import createLogger from "utils/logger"; import { formatApiCall } from "utils/proxy/api-helpers"; @@ -41,7 +42,7 @@ function getSafeSegments(rawSegments, allowedSegments) { return safeSegments; } -export default async function handler(req, res) { +async function handler(req, res) { try { const { service, group, index } = req.query; const serviceWidget = await getServiceWidget(group, service, index); @@ -144,3 +145,5 @@ export default async function handler(req, res) { return res.status(500).send({ error: "Unexpected error" }); } } + +export default withAuth(handler); diff --git a/src/pages/api/siteMonitor.js b/src/pages/api/siteMonitor.js index 072d3d4c6..71bde574e 100644 --- a/src/pages/api/siteMonitor.js +++ b/src/pages/api/siteMonitor.js @@ -1,12 +1,13 @@ import { performance } from "perf_hooks"; +import { withAuth } from "utils/auth"; import { getServiceItem } from "utils/config/service-helpers"; import createLogger from "utils/logger"; import { httpProxy } from "utils/proxy/http"; const logger = createLogger("siteMonitor"); -export default async function handler(req, res) { +async function handler(req, res) { const { groupName, serviceName } = req.query; const serviceItem = await getServiceItem(groupName, serviceName); if (!serviceItem) { @@ -50,3 +51,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/theme.js b/src/pages/api/theme.js index 41cd49b88..1b7d1cd92 100644 --- a/src/pages/api/theme.js +++ b/src/pages/api/theme.js @@ -1,6 +1,7 @@ +import { withAuth } from "utils/auth"; import checkAndCopyConfig, { getSettings } from "utils/config/config"; -export default function handler({ res }) { +function handler(req, res) { checkAndCopyConfig("settings.yaml"); const settings = getSettings(); @@ -12,3 +13,5 @@ export default function handler({ res }) { theme, }); } + +export default withAuth(handler); diff --git a/src/pages/api/validate.js b/src/pages/api/validate.js index aa892072f..9df43cebb 100644 --- a/src/pages/api/validate.js +++ b/src/pages/api/validate.js @@ -1,10 +1,11 @@ +import { withAuth } from "utils/auth"; import checkAndCopyConfig from "utils/config/config"; import createLogger from "utils/logger"; const configs = ["docker.yaml", "settings.yaml", "services.yaml", "bookmarks.yaml", "kubernetes.yaml", "proxmox.yaml"]; const logger = createLogger("configValidationHandler"); -export default async function handler(req, res) { +async function handler(req, res) { let errors = configs.map((config) => checkAndCopyConfig(config)).filter((status) => status !== true); if (errors.length > 0) { logger.error("Configuration validation errors", errors); @@ -17,3 +18,5 @@ export default async function handler(req, res) { } res.send(errors); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/customapi.js b/src/pages/api/widgets/customapi.js index 1fbb4c7fc..96c4ac93d 100644 --- a/src/pages/api/widgets/customapi.js +++ b/src/pages/api/widgets/customapi.js @@ -1,3 +1,4 @@ +import { withAuth } from "utils/auth"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import createLogger from "utils/logger"; import { sanitizeErrorURL } from "utils/proxy/api-helpers"; @@ -5,7 +6,7 @@ import { httpProxy } from "utils/proxy/http"; const logger = createLogger("customapi"); -export default async function handler(req, res) { +async function handler(req, res) { const { index } = req.query; const options = await getPrivateWidgetOptions("customapi", index); @@ -44,3 +45,5 @@ export default async function handler(req, res) { return res.status(500).json({ error: { message: "Invalid JSON", url: sanitizeErrorURL(url) } }); } } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/glances.js b/src/pages/api/widgets/glances.js index 7234ba77d..0616ccf56 100644 --- a/src/pages/api/widgets/glances.js +++ b/src/pages/api/widgets/glances.js @@ -1,3 +1,4 @@ +import { withAuth } from "utils/auth"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import createLogger from "utils/logger"; import { parseVersionForUrl } from "utils/proxy/api-helpers"; @@ -42,7 +43,7 @@ async function retrieveFromGlancesAPI(privateWidgetOptions, endpoint) { return JSON.parse(Buffer.from(data).toString()); } -export default async function handler(req, res) { +async function handler(req, res) { const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query; const privateWidgetOptions = await getPrivateWidgetOptions("glances", index); @@ -76,3 +77,5 @@ export default async function handler(req, res) { return res.status(400).json({ error: e.message }); } } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/index.js b/src/pages/api/widgets/index.js index 513e02e91..cfce525c8 100644 --- a/src/pages/api/widgets/index.js +++ b/src/pages/api/widgets/index.js @@ -1,5 +1,8 @@ +import { withAuth } from "utils/auth"; import { widgetsResponse } from "utils/config/api-response"; -export default async function handler(req, res) { +async function handler(req, res) { res.send(await widgetsResponse()); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/kubernetes.js b/src/pages/api/widgets/kubernetes.js index 7f972e224..24e9ff6df 100644 --- a/src/pages/api/widgets/kubernetes.js +++ b/src/pages/api/widgets/kubernetes.js @@ -4,9 +4,11 @@ import { getKubeConfig } from "../../../utils/config/kubernetes"; import { parseCpu, parseMemory } from "../../../utils/kubernetes/utils"; import createLogger from "../../../utils/logger"; +import { withAuth } from "utils/auth"; + const logger = createLogger("widget"); -export default async function handler(req, res) { +async function handler(req, res) { try { const kc = getKubeConfig(); if (!kc) { @@ -98,3 +100,5 @@ export default async function handler(req, res) { }); } } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/longhorn.js b/src/pages/api/widgets/longhorn.js index 2291f7cfc..d22477e74 100644 --- a/src/pages/api/widgets/longhorn.js +++ b/src/pages/api/widgets/longhorn.js @@ -2,6 +2,8 @@ import { getSettings } from "../../../utils/config/config"; import createLogger from "../../../utils/logger"; import { httpProxy } from "../../../utils/proxy/http"; +import { withAuth } from "utils/auth"; + const logger = createLogger("longhorn"); function parseLonghornData(data) { @@ -47,7 +49,7 @@ function parseLonghornData(data) { return nodes; } -export default async function handler(req, res) { +async function handler(req, res) { const settings = getSettings(); const longhornSettings = settings?.providers?.longhorn || {}; const { url, username, password } = longhornSettings; @@ -85,3 +87,5 @@ export default async function handler(req, res) { nodes, }); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/openmeteo.js b/src/pages/api/widgets/openmeteo.js index 28f2e4f00..14e7e1bda 100644 --- a/src/pages/api/widgets/openmeteo.js +++ b/src/pages/api/widgets/openmeteo.js @@ -1,9 +1,12 @@ +import { withAuth } from "utils/auth"; import { cachedRequest } from "utils/proxy/http"; -export default async function handler(req, res) { +async function handler(req, res) { const { latitude, longitude, units, cache, timezone } = req.query; const degrees = units === "metric" ? "celsius" : "fahrenheit"; const timezeone = timezone ?? "auto"; const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=sunrise,sunset¤t_weather=true&temperature_unit=${degrees}&timezone=${timezeone}`; return res.send(await cachedRequest(apiUrl, cache)); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/openweathermap.js b/src/pages/api/widgets/openweathermap.js index 993ee1f5e..b8f460f6e 100644 --- a/src/pages/api/widgets/openweathermap.js +++ b/src/pages/api/widgets/openweathermap.js @@ -1,8 +1,9 @@ +import { withAuth } from "utils/auth"; import { getSettings } from "utils/config/config"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import { cachedRequest } from "utils/proxy/http"; -export default async function handler(req, res) { +async function handler(req, res) { const { latitude, longitude, units, provider, cache, lang, index } = req.query; const privateWidgetOptions = await getPrivateWidgetOptions("openweathermap", index); let { apiKey } = privateWidgetOptions; @@ -28,3 +29,5 @@ export default async function handler(req, res) { return res.send(await cachedRequest(apiUrl, cache)); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/resources.js b/src/pages/api/widgets/resources.js index 812d3e7c6..e1ec1d581 100644 --- a/src/pages/api/widgets/resources.js +++ b/src/pages/api/widgets/resources.js @@ -1,5 +1,6 @@ import si from "systeminformation"; +import { withAuth } from "utils/auth"; import createLogger from "utils/logger"; const logger = createLogger("resources"); @@ -19,7 +20,7 @@ function isMissingNetworkStat(networkData, interfaceName) { ); } -export default async function handler(req, res) { +async function handler(req, res) { const { type, target, interfaceName = "default" } = req.query; if (type === "cpu") { @@ -114,3 +115,5 @@ export default async function handler(req, res) { error: "invalid type", }); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/stocks.js b/src/pages/api/widgets/stocks.js index 7504f6074..1e7b42dc3 100644 --- a/src/pages/api/widgets/stocks.js +++ b/src/pages/api/widgets/stocks.js @@ -1,10 +1,11 @@ +import { withAuth } from "utils/auth"; import { getSettings } from "utils/config/config"; import createLogger from "utils/logger"; import { cachedRequest } from "utils/proxy/http"; const logger = createLogger("stocks"); -export default async function handler(req, res) { +async function handler(req, res) { const { watchlist, provider, cache } = req.query; logger.debug("Stocks API request: %o", { watchlist, provider, cache }); @@ -81,3 +82,5 @@ export default async function handler(req, res) { /* c8 ignore next 2 -- provider validation above currently makes this unreachable */ return res.status(400).json({ error: "Invalid configuration" }); } + +export default withAuth(handler); diff --git a/src/pages/api/widgets/weather.js b/src/pages/api/widgets/weather.js index 78418f74b..11ed05415 100644 --- a/src/pages/api/widgets/weather.js +++ b/src/pages/api/widgets/weather.js @@ -1,8 +1,9 @@ +import { withAuth } from "utils/auth"; import { getSettings } from "utils/config/config"; import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; import { cachedRequest } from "utils/proxy/http"; -export default async function handler(req, res) { +async function handler(req, res) { const { latitude, longitude, provider, cache, lang, index } = req.query; const privateWidgetOptions = await getPrivateWidgetOptions("weatherapi", index); let { apiKey } = privateWidgetOptions; @@ -28,3 +29,5 @@ export default async function handler(req, res) { return res.send(await cachedRequest(apiUrl, cache)); } + +export default withAuth(handler); diff --git a/src/pages/browserconfig.xml.jsx b/src/pages/browserconfig.xml.jsx index 7721bf226..528e0570a 100644 --- a/src/pages/browserconfig.xml.jsx +++ b/src/pages/browserconfig.xml.jsx @@ -1,7 +1,8 @@ +import { withPageAuth } from "utils/auth"; import { getSettings } from "utils/config/config"; import themes from "utils/styles/themes"; -export async function getServerSideProps({ res }) { +async function getProps({ res }) { const settings = getSettings(); const color = settings.color || "slate"; @@ -27,3 +28,5 @@ export async function getServerSideProps({ res }) { } export default function BrowserConfig() {} + +export const getServerSideProps = withPageAuth(getProps); diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 304e2ffa5..ddf29cf1f 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -1,6 +1,5 @@ import classNames from "classnames"; import { useTranslation } from "next-i18next/pages"; -import { serverSideTranslations } from "next-i18next/pages/serverSideTranslations"; import dynamic from "next/dynamic"; import Head from "next/head"; import { useRouter } from "next/router"; @@ -16,14 +15,13 @@ import ServicesGroup from "components/services/group"; import Tab, { slugifyAndEncode } from "components/tab"; import Revalidate from "components/toggles/revalidate"; import Widget from "components/widgets/widget"; -import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/config/api-response"; -import { getSettings } from "utils/config/config"; +import { withPageAuth } from "utils/auth"; +import { getDashboardData } from "utils/config/dashboard"; import { ColorContext } from "utils/contexts/color"; import { SettingsContext } from "utils/contexts/settings"; import { TabContext } from "utils/contexts/tab"; import { ThemeContext } from "utils/contexts/theme"; import useWindowFocus from "utils/hooks/window-focus"; -import createLogger from "utils/logger"; import themes from "utils/styles/themes"; const ThemeToggle = dynamic(() => import("components/toggles/theme"), { @@ -55,47 +53,7 @@ const normalizeLanguage = (language) => { return alias || language; }; -export async function getStaticProps() { - let logger; - try { - logger = createLogger("index"); - const { providers, ...settings } = getSettings(); - - const services = await servicesResponse(); - const bookmarks = await bookmarksResponse(); - const widgets = await widgetsResponse(); - const language = normalizeLanguage(settings.language); - - return { - props: { - initialSettings: settings, - fallback: { - "/api/services": services, - "/api/bookmarks": bookmarks, - "/api/widgets": widgets, - "/api/hash": false, - }, - ...(await serverSideTranslations(language)), - }, - }; - } catch (e) { - if (logger && e) { - logger.error(e); - } - return { - props: { - initialSettings: {}, - fallback: { - "/api/services": [], - "/api/bookmarks": [], - "/api/widgets": [], - "/api/hash": false, - }, - ...(await serverSideTranslations("en")), - }, - }; - } -} +export const getServerSideProps = withPageAuth(async () => ({ props: await getDashboardData() })); function Index({ initialSettings, fallback }) { const windowFocused = useWindowFocus(); diff --git a/src/pages/site.webmanifest.jsx b/src/pages/site.webmanifest.jsx index bfaa92a27..84a3775c0 100644 --- a/src/pages/site.webmanifest.jsx +++ b/src/pages/site.webmanifest.jsx @@ -1,7 +1,8 @@ +import { withPageAuth } from "utils/auth"; import checkAndCopyConfig, { getSettings } from "utils/config/config"; import themes from "utils/styles/themes"; -export async function getServerSideProps({ res }) { +async function getProps({ res }) { checkAndCopyConfig("settings.yaml"); const settings = getSettings(); @@ -44,3 +45,5 @@ export async function getServerSideProps({ res }) { export default function Webmanifest() { return null; } + +export const getServerSideProps = withPageAuth(getProps); diff --git a/src/utils/auth.js b/src/utils/auth.js new file mode 100644 index 000000000..9d268bd36 --- /dev/null +++ b/src/utils/auth.js @@ -0,0 +1,75 @@ +import { getToken } from "next-auth/jwt"; + +import { isAuthEnabled } from "utils/env"; + +export function isAllowedHost(host) { + if (process.env.HOMEPAGE_ALLOWED_HOSTS === "*") return true; + const port = process.env.PORT || 3000; + const allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`].concat( + process.env.HOMEPAGE_ALLOWED_HOSTS ? process.env.HOMEPAGE_ALLOWED_HOSTS.split(",") : [], + ); + if (host && allowedHosts.includes(host)) return true; + console.error( + `Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`, + ); + return false; +} + +export function withAllowedHost(handler) { + return async function hostCheckedHandler(req, res) { + if (!isAllowedHost(req.headers?.host)) { + return res.status(400).json({ error: "Host validation failed. See logs for more details." }); + } + return handler(req, res); + }; +} + +// Match NextAuth, which picks the cookie name from the parsed URL protocol +export function secureCookie() { + try { + return new URL(process.env.NEXTAUTH_URL || process.env.HOMEPAGE_EXTERNAL_URL).protocol === "https:"; + } catch { + return false; + } +} + +async function authorized(req, res) { + if (!isAuthEnabled()) return true; + + res.setHeader("Cache-Control", "private, no-store"); + return Boolean( + await getToken({ + req, + secret: process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET, + secureCookie: secureCookie(), + }), + ); +} + +export function withAuth(handler, { public: isPublic } = {}) { + return withAllowedHost(async function authenticatedHandler(req, res) { + if (!isPublic?.(req) && !(await authorized(req, res))) { + return res.status(401).json({ error: "Unauthorized" }); + } + return handler(req, res); + }); +} + +export function withPageAuth(getProps) { + return async function authenticatedPage(context) { + if (!isAllowedHost(context.req?.headers?.host)) { + return { notFound: true }; + } + if (!(await authorized(context.req, context.res))) { + // resolvedUrl is a path supplied by Next.js, never an external destination. + const callbackUrl = context.resolvedUrl || "/"; + return { + redirect: { + destination: `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`, + permanent: false, + }, + }; + } + return getProps(context); + }; +} diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js new file mode 100644 index 000000000..3f481e10b --- /dev/null +++ b/src/utils/auth.test.js @@ -0,0 +1,180 @@ +import { encode } from "next-auth/jwt"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; +import { isAllowedHost, secureCookie, withAllowedHost, withAuth, withPageAuth } from "utils/auth"; + +describe("handler authentication", () => { + const secret = "test-session-secret-at-least-32-characters"; + + beforeEach(() => { + vi.stubEnv("HOMEPAGE_AUTH_ENABLED", "true"); + vi.stubEnv("HOMEPAGE_AUTH_SECRET", secret); + vi.stubEnv("HOMEPAGE_EXTERNAL_URL", "http://localhost:3000"); + vi.stubEnv("NEXTAUTH_SECRET", ""); + vi.stubEnv("NEXTAUTH_URL", ""); + }); + afterEach(() => vi.unstubAllEnvs()); + + const request = (cookies = {}) => ({ headers: { "x-prerender-revalidate": "public-image-key" }, cookies }); + + it("rejects anonymous requests before the API handler runs, independently of middleware", async () => { + const handler = vi.fn(); + const res = createMockRes(); + await withAuth(handler)(request(), res); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + expect(res.headers["Cache-Control"]).toBe("private, no-store"); + expect(handler).not.toHaveBeenCalled(); + }); + + describe.each(["HOMEPAGE_EXTERNAL_URL", "NEXTAUTH_URL"])("session cookies with %s", (variable) => { + it.each([ + ["http://homepage.example", false], + ["https://homepage.example", true], + ["HTTP://homepage.example", false], + ["HTTPS://homepage.example", true], + [" https://homepage.example ", true], + [" http://homepage.example ", false], + ])("accepts a real session for %s", async (url, secure) => { + vi.stubEnv(variable, url); + const cookie = secure ? "__Secure-next-auth.session-token" : "next-auth.session-token"; + const token = await encode({ token: { sub: "homepage" }, secret }); + const req = request({ [cookie]: token }); + const res = createMockRes(); + const handler = vi.fn(() => res.send("private data")); + await withAuth(handler)(req, res); + expect(handler).toHaveBeenCalledWith(req, res); + expect(res.body).toBe("private data"); + expect(res.headers["Cache-Control"]).toBe("private, no-store"); + + const getProps = vi.fn(() => ({ props: { title: "private page" } })); + expect(await withPageAuth(getProps)({ req, res })).toEqual({ props: { title: "private page" } }); + expect(getProps).toHaveBeenCalledOnce(); + }); + }); + + it.each(["expired", "wrong secret", "missing secret", "malformed"])("rejects %s sessions", async (kind) => { + const token = + kind === "malformed" + ? "broken-token" + : await encode({ + token: { sub: "homepage" }, + secret: kind === "wrong secret" ? "different-secret" : secret, + maxAge: kind === "expired" ? -60 : 3600, + }); + if (kind === "missing secret") vi.stubEnv("HOMEPAGE_AUTH_SECRET", ""); + const handler = vi.fn(); + const res = createMockRes(); + await withAuth(handler)(request({ "next-auth.session-token": token }), res); + expect(res.statusCode).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); + + it("allows auth-disabled installations and explicit public resources", async () => { + const handler = vi.fn(); + await withAuth(handler, { public: () => true })(request(), createMockRes()); + vi.stubEnv("HOMEPAGE_AUTH_ENABLED", "false"); + await withAuth(handler)(request(), createMockRes()); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("redirects a page before reading or returning its private props", async () => { + const getProps = vi.fn(); + const res = createMockRes(); + const result = await withPageAuth(getProps)({ req: request(), res, resolvedUrl: "/?tab=private" }); + expect(result.redirect).toEqual({ destination: "/auth/signin?callbackUrl=%2F%3Ftab%3Dprivate", permanent: false }); + expect(res.headers["Cache-Control"]).toBe("private, no-store"); + expect(getProps).not.toHaveBeenCalled(); + }); +}); + +describe("handler host validation", () => { + beforeEach(() => { + vi.stubEnv("HOMEPAGE_ALLOWED_HOSTS", ""); + vi.stubEnv("PORT", "3000"); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it.each([ + ["localhost:3000", true], + ["127.0.0.1:3000", true], + ["[::1]:3000", true], + ["evil.example", false], + [undefined, false], + ])("checks %s against the default hosts", (host, allowed) => { + expect(isAllowedHost(host)).toBe(allowed); + }); + + it("accepts configured hosts and the wildcard", () => { + vi.stubEnv("HOMEPAGE_ALLOWED_HOSTS", "homepage.example,other:3000"); + expect(isAllowedHost("homepage.example")).toBe(true); + expect(isAllowedHost("evil.example")).toBe(false); + vi.stubEnv("HOMEPAGE_ALLOWED_HOSTS", "*"); + expect(isAllowedHost("evil.example")).toBe(true); + }); + + it("rejects disallowed hosts before public or authenticated handlers run", async () => { + const handler = vi.fn(); + const req = { headers: { host: "evil.example", "x-prerender-revalidate": "public-image-key" }, cookies: {} }; + for (const wrapped of [withAllowedHost(handler), withAuth(handler, { public: () => true })]) { + const res = createMockRes(); + await wrapped(req, res); + expect(res.statusCode).toBe(400); + } + expect(handler).not.toHaveBeenCalled(); + }); + + it("hides pages from disallowed hosts before reading their props", async () => { + const getProps = vi.fn(); + const result = await withPageAuth(getProps)({ + req: { headers: { host: "evil.example" }, cookies: {} }, + res: createMockRes(), + resolvedUrl: "/", + }); + expect(result).toEqual({ notFound: true }); + expect(getProps).not.toHaveBeenCalled(); + }); +}); + +describe("secureCookie", () => { + beforeEach(() => { + vi.stubEnv("NEXTAUTH_URL", ""); + vi.stubEnv("HOMEPAGE_EXTERNAL_URL", ""); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each([ + ["https://homepage.example", true], + ["HTTPS://homepage.example", true], + [" https://homepage.example ", true], + ["http://homepage.example", false], + ["homepage.example", false], + ["not a url", false], + ["", false], + ])("treats %j as secure: %s", (url, secure) => { + vi.stubEnv("HOMEPAGE_EXTERNAL_URL", url); + expect(secureCookie()).toBe(secure); + }); + + it("prefers NEXTAUTH_URL, as NextAuth does", () => { + vi.stubEnv("NEXTAUTH_URL", "https://homepage.example"); + vi.stubEnv("HOMEPAGE_EXTERNAL_URL", "http://homepage.example"); + expect(secureCookie()).toBe(true); + }); + + it("rejects instead of erroring when the URL is invalid", async () => { + vi.stubEnv("HOMEPAGE_AUTH_ENABLED", "true"); + vi.stubEnv("HOMEPAGE_AUTH_SECRET", "test-session-secret-at-least-32-characters"); + vi.stubEnv("HOMEPAGE_EXTERNAL_URL", "homepage.example"); + const handler = vi.fn(); + const res = createMockRes(); + await withAuth(handler)({ headers: {}, cookies: {} }, res); + expect(res.statusCode).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/config/dashboard.js b/src/utils/config/dashboard.js new file mode 100644 index 000000000..ab0e51332 --- /dev/null +++ b/src/utils/config/dashboard.js @@ -0,0 +1,54 @@ +import cache from "memory-cache"; +import { serverSideTranslations } from "next-i18next/pages/serverSideTranslations"; + +import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/config/api-response"; +import { getSettings } from "utils/config/config"; +import createLogger from "utils/logger"; + +const cacheKey = "homepageDashboardData"; + +export function invalidateDashboardData() { + cache.del(cacheKey); +} + +async function loadDashboardData() { + const { providers, ...settings } = getSettings(); + const language = settings.language?.toLowerCase() === "zh-cn" ? "zh-Hans" : settings.language || "en"; + const [services, bookmarks, widgets, translations] = await Promise.all([ + servicesResponse(), + bookmarksResponse(), + widgetsResponse(), + serverSideTranslations(language), + ]); + return { + initialSettings: settings, + fallback: { + "/api/services": services, + "/api/bookmarks": bookmarks, + "/api/widgets": widgets, + "/api/hash": false, + }, + ...translations, + }; +} + +export async function getDashboardData() { + let pending = cache.get(cacheKey); + if (!pending) { + pending = loadDashboardData().catch((error) => { + if (cache.get(cacheKey) === pending) invalidateDashboardData(); + throw error; + }); + cache.put(cacheKey, pending); + } + try { + return await pending; + } catch (error) { + createLogger("index").error(error); + return { + initialSettings: {}, + fallback: { "/api/services": [], "/api/bookmarks": [], "/api/widgets": [], "/api/hash": false }, + ...(await serverSideTranslations("en")), + }; + } +} diff --git a/src/utils/config/dashboard.test.js b/src/utils/config/dashboard.test.js new file mode 100644 index 000000000..d6ba24df6 --- /dev/null +++ b/src/utils/config/dashboard.test.js @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { services, bookmarks, widgets, translations, settings, logger } = vi.hoisted(() => ({ + services: vi.fn(), + bookmarks: vi.fn(), + widgets: vi.fn(), + translations: vi.fn(), + settings: vi.fn(), + logger: { error: vi.fn() }, +})); +vi.mock("utils/config/api-response", () => ({ + servicesResponse: services, + bookmarksResponse: bookmarks, + widgetsResponse: widgets, +})); +vi.mock("utils/config/config", () => ({ getSettings: settings })); +vi.mock("next-i18next/pages/serverSideTranslations", () => ({ serverSideTranslations: translations })); +vi.mock("utils/logger", () => ({ default: () => logger })); + +import { getDashboardData, invalidateDashboardData } from "utils/config/dashboard"; + +describe("dashboard data snapshot", () => { + beforeEach(() => { + vi.resetAllMocks(); + invalidateDashboardData(); + services.mockResolvedValue([{ name: "private service" }]); + bookmarks.mockResolvedValue([]); + widgets.mockResolvedValue([]); + translations.mockResolvedValue({ _nextI18Next: { initialLocale: "en" } }); + settings.mockReturnValue({ providers: { privateKey: "secret" }, title: "Dashboard" }); + }); + + it("coalesces concurrent discovery and reuses the snapshot for later page loads", async () => { + const results = await Promise.all(Array.from({ length: 10 }, () => getDashboardData())); + const later = await getDashboardData(); + for (const loader of [services, bookmarks, widgets, translations, settings]) expect(loader).toHaveBeenCalledOnce(); + expect(results.every((result) => result === later)).toBe(true); + expect(later.initialSettings).toEqual({ title: "Dashboard" }); + expect(later.fallback["/api/services"]).toEqual([{ name: "private service" }]); + }); + + it("refreshes configuration and discovery after invalidation", async () => { + await getDashboardData(); + invalidateDashboardData(); + settings.mockReturnValue({ title: "Updated", language: "zh-CN" }); + const result = await getDashboardData(); + expect(services).toHaveBeenCalledTimes(2); + expect(result.initialSettings.title).toBe("Updated"); + expect(translations).toHaveBeenLastCalledWith("zh-Hans"); + }); + + it("retries after a failed load instead of caching an empty dashboard", async () => { + services.mockRejectedValueOnce(new Error("discovery failed")); + expect((await getDashboardData()).fallback["/api/services"]).toEqual([]); + expect((await getDashboardData()).fallback["/api/services"]).toEqual([{ name: "private service" }]); + expect(services).toHaveBeenCalledTimes(2); + }); + + it("does not let a stale failed load evict a newer snapshot", async () => { + let reject; + services.mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail; + }), + ); + const stale = getDashboardData(); + invalidateDashboardData(); + const current = await getDashboardData(); + reject(new Error("old discovery failed")); + await stale; + expect(await getDashboardData()).toBe(current); + expect(services).toHaveBeenCalledTimes(2); + }); +}); diff --git a/vitest.setup.js b/vitest.setup.js index 6c03bd6d7..6ec5eee1e 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -8,6 +8,8 @@ afterEach(() => { if (typeof document !== "undefined") cleanup(); }); +process.env.HOMEPAGE_ALLOWED_HOSTS = "*"; + // Avoid NextAuth client-side fetches during unit tests. vi.mock("next-auth/react", () => ({ SessionProvider: ({ children }) => children ?? null,