Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ Example: `https://my-app.example.com` or `https://app-a.example.com,https://app-
- Default: cross-origin requests blocked
- Type: `string` (comma-separated for multiple origins)

### `PROXY_SERVER_ALLOWED_DB_ORIGINS`

Restricts which database origins the proxy server will forward requests to. When set, requests targeting an origin not in the list receive a 403 response. When not set, the proxy forwards to any database URL specified by the client (current default behavior).

Each entry must be an origin only (scheme + host + port) — paths are not supported and will cause a startup error. The proxy also disables HTTP redirects on outbound requests, preventing an allowed origin from redirecting to an unrelated internal service.

Examples:

```bash
# Single origin
PROXY_SERVER_ALLOWED_DB_ORIGINS=https://my-neptune-cluster:8182

# Multiple origins
PROXY_SERVER_ALLOWED_DB_ORIGINS=https://cluster-a:8182,https://cluster-b:8182
```

- Optional
- Default: all origins allowed
- Type: `string` (comma-separated for multiple origins)

### `LOG_STYLE`

Controls the log output format.
Expand Down
4 changes: 4 additions & 0 deletions docs/references/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ When set, browsers will block cross-origin requests from any other origin. This
>
> CORS headers only affect browser-initiated requests — direct API calls from scripts or other servers are not restricted by CORS. CORS is a defense-in-depth layer, not a substitute for authentication or network-level access controls. Ensure the proxy server is not exposed to untrusted networks.

## Database Origin Allowlist

By default, the proxy server forwards requests to any database URL specified by the client. You can restrict which database origins the proxy will contact by setting [`PROXY_SERVER_ALLOWED_DB_ORIGINS`](./configuration.md#proxy_server_allowed_db_origins). Requests targeting an unlisted origin receive a 403 response.

## Permissions

Graph Explorer does not provide any mechanisms for controlling user permissions. If you are using Graph Explorer with AWS, Neptune permissions can be controlled through IAM roles.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { assertAllowedDbOrigin } from "./allowed-db-origins.ts";
import { HttpError } from "./errors.ts";

describe("assertAllowedDbOrigin", () => {
it("does nothing when allowlist is undefined (permissive)", () => {
expect(() =>
assertAllowedDbOrigin("https://neptune:8182", undefined),
).not.toThrow();
});

it("does nothing when the origin is in the allowlist", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() =>
assertAllowedDbOrigin("https://neptune:8182/sparql", allowed),
).not.toThrow();
});

it("throws HttpError 403 when the origin is not in the allowlist", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() => assertAllowedDbOrigin("https://evil:9999", allowed)).toThrow(
expect.objectContaining({
status: 403,
message: expect.stringContaining("https://evil:9999"),
}),
);
expect(() => assertAllowedDbOrigin("https://evil:9999", allowed)).toThrow(
expect.objectContaining({
message: expect.stringContaining("administrator"),
}),
);
});

it("is case insensitive (URL constructor normalizes)", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() =>
assertAllowedDbOrigin("HTTPS://NEPTUNE:8182/gremlin", allowed),
).not.toThrow();
});

it("ignores trailing slashes (origin strips path)", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() =>
assertAllowedDbOrigin("https://neptune:8182/", allowed),
).not.toThrow();
});

it("treats different ports as different origins", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() =>
assertAllowedDbOrigin("https://neptune:8183", allowed),
).toThrow(HttpError);
});

it("treats different schemes as different origins", () => {
const allowed = new Set(["https://neptune:8182"]);
expect(() => assertAllowedDbOrigin("http://neptune:8182", allowed)).toThrow(
HttpError,
);
});

it("rejects all requests when allowlist is an empty set", () => {
const allowed = new Set<string>();
expect(() =>
assertAllowedDbOrigin("https://neptune:8182", allowed),
).toThrow(HttpError);
});

it("normalizes default ports (443 for https, 80 for http)", () => {
const allowedHttps = new Set(["https://neptune"]);
expect(() =>
assertAllowedDbOrigin("https://neptune:443/sparql", allowedHttps),
).not.toThrow();

const allowedHttp = new Set(["http://neptune"]);
expect(() =>
assertAllowedDbOrigin("http://neptune:80/sparql", allowedHttp),
).not.toThrow();
});
});
17 changes: 17 additions & 0 deletions packages/graph-explorer-proxy-server/src/allowed-db-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpError } from "./errors.ts";

export function assertAllowedDbOrigin(
url: string,
allowedOrigins: Set<string> | undefined,
) {
if (!allowedOrigins) {
return;
}
const origin = new URL(url).origin;
if (!allowedOrigins.has(origin)) {
throw new HttpError(
403,
`Database origin "${origin}" is not in the allowed origins list. Contact your administrator.`,
);
}
}
76 changes: 75 additions & 1 deletion packages/graph-explorer-proxy-server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ const mockFetch = vi.mocked(fetch);

const testVersion = "1.2.3";

function createTestApp(configPath = ".", corsOrigin?: string[]) {
function createTestApp(
configPath = ".",
corsOrigin?: string[],
allowedDbOrigins?: Set<string>,
) {
const app = createApp({
configPath,
staticFilesVirtualPath: "/explorer",
staticFilesPath: ".",
version: testVersion,
corsOrigin,
allowedDbOrigins,
});
app.locals.logger = createLogger({
HOST: "localhost",
Expand Down Expand Up @@ -927,5 +932,74 @@ describe("createApp", () => {

expect(response.status).toBe(500);
});

it("disables HTTP redirects on outbound requests", async () => {
mockFetchOnce();

const app = createTestApp();
await request(app)
.post("/sparql")
.set(dbHeaders())
.send({ query: "SELECT 1" });

const fetchOptions = mockFetch.mock.calls[0][1] as any;
expect(fetchOptions.redirect).toBe("error");
});
});

// ── Allowed DB origins ─────────────────────────────────────────────

describe("PROXY_SERVER_ALLOWED_DB_ORIGINS", () => {
const allowedOrigins = new Set(["https://my-graph-db.example.com:8182"]);

it("allows requests when the origin is in the allowlist", async () => {
mockFetchOnce(JSON.stringify({ results: [] }), 200, {
"content-type": "application/json",
});

const app = createTestApp(".", undefined, allowedOrigins);
const response = await request(app)
.post("/sparql")
.set(dbHeaders())
.send({ query: "SELECT 1" });

expect(response.status).toBe(200);
});

it("allows all requests when allowlist is not configured", async () => {
mockFetchOnce();

const app = createTestApp();
const response = await request(app)
.post("/sparql")
.set(dbHeaders())
.send({ query: "SELECT 1" });

expect(response.status).toBe(200);
});

it.each([
{ method: "post", route: "/sparql", body: { query: "test" } },
{ method: "post", route: "/gremlin", body: { query: "test" } },
{ method: "post", route: "/openCypher", body: { query: "test" } },
{ method: "get", route: "/summary", body: undefined },
{ method: "get", route: "/pg/statistics/summary", body: undefined },
{ method: "get", route: "/rdf/statistics/summary", body: undefined },
] as const)(
"$method $route returns 403 for disallowed origin without calling fetch",
async ({ method, route, body }) => {
const app = createTestApp(".", undefined, allowedOrigins);
const req = request(app)
[method](route)
.set(
dbHeaders({ "graph-db-connection-url": "https://blocked:8182" }),
);
const response = body ? await req.send(body) : await req;

expect(response.status).toBe(403);
expect(response.body.error.message).toContain("allowed origins list");
expect(mockFetch).not.toHaveBeenCalled();
},
);
});
});
12 changes: 11 additions & 1 deletion packages/graph-explorer-proxy-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import path from "path";
import { pipeline } from "stream";
import { z } from "zod";

import { assertAllowedDbOrigin } from "./allowed-db-origins.ts";
import { errorHandlingMiddleware } from "./error-handler.ts";
import { RequestValidationError } from "./errors.ts";
import { type AppLogger, requestLoggingMiddleware } from "./logging.ts";
Expand Down Expand Up @@ -80,6 +81,7 @@ interface CreateAppOptions {
staticFilesPath: string;
version?: string;
corsOrigin?: string[];
allowedDbOrigins?: Set<string>;
}

export function createApp({
Expand All @@ -88,6 +90,7 @@ export function createApp({
staticFilesPath,
version,
corsOrigin,
allowedDbOrigins,
}: CreateAppOptions): express.Express {
const app = express();

Expand Down Expand Up @@ -180,7 +183,8 @@ export function createApp({
method: options.method,
body: options.body ?? undefined,
headers: options.headers,
compress: false, // prevent automatic decompression
compress: false,
redirect: "error",
};

try {
Expand Down Expand Up @@ -272,6 +276,7 @@ export function createApp({
region,
serviceType,
} = parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);

/// Function to cancel long running queries if the client disappears before completion
async function cancelQuery() {
Expand Down Expand Up @@ -366,6 +371,7 @@ export function createApp({
region,
serviceType,
} = parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);

// Validate the input before making any external calls.
const queryString = req.body.query;
Expand Down Expand Up @@ -450,6 +456,7 @@ export function createApp({
region,
serviceType,
} = parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);

const queryString = req.body.query;
// Validate the input before making any external calls.
Expand Down Expand Up @@ -490,6 +497,7 @@ export function createApp({
app.get("/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"summary?mode=detailed",
Expand All @@ -510,6 +518,7 @@ export function createApp({
app.get("/pg/statistics/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"pg/statistics/summary?mode=detailed",
Expand All @@ -530,6 +539,7 @@ export function createApp({
app.get("/rdf/statistics/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
assertAllowedDbOrigin(graphDbConnectionUrl, allowedDbOrigins);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"rdf/statistics/summary?mode=detailed",
Expand Down
Loading
Loading