diff --git a/server/routes/index.test.ts b/server/routes/index.test.ts
index 005f162561..319189b105 100644
--- a/server/routes/index.test.ts
+++ b/server/routes/index.test.ts
@@ -163,3 +163,36 @@ describe("/s/:id", () => {
expect(body).not.toContain("[Child Document]");
});
});
+
+describe("scanner path 404s", () => {
+ it.each([
+ "/.well-known/gpc.json",
+ "/.env",
+ "/.git/config",
+ "/cgi-bin/test.cgi",
+ "/wp-admin/setup-config.php",
+ "/wp-login.php",
+ "/xmlrpc.php",
+ "/admin.php",
+ "/phpmyadmin/index.php",
+ "/actuator/health",
+ "/HNAP1/",
+ ])("returns 404 for %s without rendering the app shell", async (path) => {
+ const res = await server.get(path);
+ const body = await res.text();
+ expect(res.status).toEqual(404);
+ expect(body).not.toContain("
");
+ });
+
+ it("still serves the app shell for legitimate unknown paths", async () => {
+ const res = await server.get("/some-app-route");
+ expect(res.status).toEqual(200);
+ });
+
+ it("still serves the OAuth well-known endpoint", async () => {
+ const res = await server.get("/.well-known/oauth-authorization-server");
+ expect(res.status).toEqual(200);
+ const body = await res.json();
+ expect(body.issuer).toBeDefined();
+ });
+});
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 0d304ec501..7bf8bf6c13 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -15,6 +15,7 @@ import { Integration } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots";
+import { isInvalidAppPath } from "@server/utils/url";
import apexRedirect from "../middlewares/apexRedirect";
import { renderApp, renderShare } from "./app";
import { renderEmbed } from "./embeds";
@@ -217,6 +218,11 @@ router.get("/sitemap.xml", async (ctx) => {
// catch all for application
router.get("*", async (ctx, next) => {
+ if (isInvalidAppPath(ctx.path)) {
+ ctx.status = 404;
+ return;
+ }
+
if (ctx.state?.rootShare) {
// Only allow root path for root share domains, return 404 for other paths.
// Valid paths like /doc/:documentSlug and /sitemap.xml are handled above.
diff --git a/server/utils/url.test.ts b/server/utils/url.test.ts
index 132a221997..ca7f34b2e3 100644
--- a/server/utils/url.test.ts
+++ b/server/utils/url.test.ts
@@ -1,7 +1,7 @@
import dns from "node:dns";
import type { MockInstance } from "vitest";
import env from "@server/env";
-import { validateUrlNotPrivate } from "./url";
+import { isInvalidAppPath, validateUrlNotPrivate } from "./url";
describe("validateUrlNotPrivate", () => {
let lookupSpy: MockInstance;
@@ -98,3 +98,36 @@ describe("validateUrlNotPrivate", () => {
});
});
});
+
+describe("isInvalidAppPath", () => {
+ it.each([
+ "/.well-known/gpc.json",
+ "/.env",
+ "/.env.production",
+ "/.git/config",
+ "/.DS_Store",
+ "/cgi-bin/test.cgi",
+ "/wp-admin/setup-config.php",
+ "/wp-login.php",
+ "/wp-content/plugins/foo",
+ "/xmlrpc.php",
+ "/admin.php",
+ "/phpmyadmin/index.php",
+ "/actuator/health",
+ "/HNAP1/",
+ "/index.php",
+ ])("returns true for scanner path %s", (path) => {
+ expect(isInvalidAppPath(path)).toBe(true);
+ });
+
+ it.each([
+ "/",
+ "/home",
+ "/doc/document-slug",
+ "/collection/abc123",
+ "/settings/account",
+ "/api/documents.list",
+ ])("returns false for legitimate path %s", (path) => {
+ expect(isInvalidAppPath(path)).toBe(false);
+ });
+});
diff --git a/server/utils/url.ts b/server/utils/url.ts
index 24df346045..61b5017c5d 100644
--- a/server/utils/url.ts
+++ b/server/utils/url.ts
@@ -18,6 +18,31 @@ const privateRanges = new Set([
export const generateUrlId = () => randomString(UrlIdLength);
+// Paths probed by vulnerability scanners.
+const scannerPathPattern = new RegExp(
+ [
+ // paths
+ "^\\/(?:cgi-bin|wp-admin|wp-content|wp-includes|wp-json|wp-login\\.php|wordpress|xmlrpc\\.php|phpmyadmin|pma|myadmin|owa|autodiscover|actuator|vendor|webdav|cms|drupal|joomla|magento|laravel|adminer|console|server-status|server-info|HNAP1|boaform|hudson|jenkins)(?:\\/|$)",
+ // file endings
+ "\\.(?:php|asp|aspx|jsp|cgi|env|sql|bak|swp|htaccess|htpasswd)(?:$|[/?])",
+ // dotfiles
+ "^\\/\\.(?:well-known|env|git|svn|aws|ssh|DS_Store)",
+ ].join("|"),
+ "i"
+);
+
+/**
+ * Checks whether a request path looks like an automated scanner probe rather
+ * than a legitimate application route, so the server can short-circuit with a
+ * 404 instead of rendering the SPA shell.
+ *
+ * @param path - the request path to check.
+ * @returns true if the path matches a known scanner pattern.
+ */
+export function isInvalidAppPath(path: string): boolean {
+ return scannerPathPattern.test(path);
+}
+
/**
* Checks if an IP address is private, loopback, or link-local.
*