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
127 changes: 117 additions & 10 deletions packages/graph-explorer-proxy-server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from "path";
import { Readable } from "stream";
import request from "supertest";

import { createApp } from "./app.ts";
import { createApp, resolveEndpointUrl } from "./app.ts";
import { createLogger } from "./logging.ts";

// node-fetch is globally mocked in test-setup.ts
Expand Down Expand Up @@ -525,7 +525,7 @@ describe("createApp", () => {
// ── Summary routes ────────────────────────────────────────────────

describe("GET /summary", () => {
it("proxies to the graph database summary endpoint", async () => {
it("proxies to the graph database summary endpoint without injecting query params", async () => {
mockFetchOnce(JSON.stringify({ graphSummary: {} }), 200, {
"content-type": "application/json",
});
Expand All @@ -535,14 +535,42 @@ describe("createApp", () => {

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/summary?mode=detailed`,
`${graphDbUrl}/summary`,
expect.objectContaining({ method: "GET" }),
);
});

it("forwards query params to the graph database", async () => {
mockFetchOnce(JSON.stringify({ graphSummary: {} }), 200, {
"content-type": "application/json",
});

const app = createTestApp();
await request(app).get("/summary?mode=basic").set(dbHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/summary?mode=basic`,
expect.objectContaining({ method: "GET" }),
);
});

it("forwards multiple query params to the graph database", async () => {
mockFetchOnce(JSON.stringify({ graphSummary: {} }), 200, {
"content-type": "application/json",
});

const app = createTestApp();
await request(app).get("/summary?mode=basic&foo=bar").set(dbHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/summary?mode=basic&foo=bar`,
expect.objectContaining({ method: "GET" }),
);
});
});

describe("GET /pg/statistics/summary", () => {
it("proxies to the PG statistics summary endpoint", async () => {
it("proxies to the PG statistics summary endpoint without injecting query params", async () => {
mockFetchOnce(JSON.stringify({ stats: {} }), 200, {
"content-type": "application/json",
});
Expand All @@ -554,14 +582,30 @@ describe("createApp", () => {

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/pg/statistics/summary?mode=detailed`,
`${graphDbUrl}/pg/statistics/summary`,
expect.objectContaining({ method: "GET" }),
);
});

it("forwards query params to the graph database", async () => {
mockFetchOnce(JSON.stringify({ stats: {} }), 200, {
"content-type": "application/json",
});

const app = createTestApp();
await request(app)
.get("/pg/statistics/summary?mode=basic")
.set(dbHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/pg/statistics/summary?mode=basic`,
expect.objectContaining({ method: "GET" }),
);
});
});

describe("GET /rdf/statistics/summary", () => {
it("proxies to the RDF statistics summary endpoint", async () => {
it("proxies to the RDF statistics summary endpoint without injecting query params", async () => {
mockFetchOnce(JSON.stringify({ stats: {} }), 200, {
"content-type": "application/json",
});
Expand All @@ -573,7 +617,23 @@ describe("createApp", () => {

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/rdf/statistics/summary?mode=detailed`,
`${graphDbUrl}/rdf/statistics/summary`,
expect.objectContaining({ method: "GET" }),
);
});

it("forwards query params to the graph database", async () => {
mockFetchOnce(JSON.stringify({ stats: {} }), 200, {
"content-type": "application/json",
});

const app = createTestApp();
await request(app)
.get("/rdf/statistics/summary?mode=basic")
.set(dbHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${graphDbUrl}/rdf/statistics/summary?mode=basic`,
expect.objectContaining({ method: "GET" }),
);
});
Expand Down Expand Up @@ -881,7 +941,7 @@ describe("createApp", () => {
await request(app).get("/summary").set(blazegraphHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${blazegraphUrl}/summary?mode=detailed`,
`${blazegraphUrl}/summary`,
expect.anything(),
);
});
Expand All @@ -893,7 +953,7 @@ describe("createApp", () => {
await request(app).get("/pg/statistics/summary").set(blazegraphHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${blazegraphUrl}/pg/statistics/summary?mode=detailed`,
`${blazegraphUrl}/pg/statistics/summary`,
expect.anything(),
);
});
Expand All @@ -907,7 +967,7 @@ describe("createApp", () => {
.set(blazegraphHeaders());

expect(mockFetch).toHaveBeenCalledWith(
`${blazegraphUrl}/rdf/statistics/summary?mode=detailed`,
`${blazegraphUrl}/rdf/statistics/summary`,
expect.anything(),
);
});
Expand All @@ -929,3 +989,50 @@ describe("createApp", () => {
});
});
});

describe("resolveEndpointUrl", () => {
it("appends a relative endpoint to the base path", () => {
const url = resolveEndpointUrl(
"https://neptune:8182",
"pg/statistics/summary",
);
expect(url.href).toBe("https://neptune:8182/pg/statistics/summary");
});

it("preserves the base path when appending", () => {
const url = resolveEndpointUrl("https://neptune:8182/blazegraph", "sparql");
expect(url.href).toBe("https://neptune:8182/blazegraph/sparql");
});

it("strips a leading slash from the endpoint", () => {
const url = resolveEndpointUrl(
"https://neptune:8182",
"/summary?mode=basic",
);
expect(url.href).toBe("https://neptune:8182/summary?mode=basic");
});

it("preserves query params from the endpoint", () => {
const url = resolveEndpointUrl(
"https://neptune:8182",
"pg/statistics/summary?mode=basic&foo=bar",
);
expect(url.href).toBe(
"https://neptune:8182/pg/statistics/summary?mode=basic&foo=bar",
);
});

it("throws if the resolved URL escapes the base origin", () => {
expect(() =>
resolveEndpointUrl("https://neptune:8182", "https://other-host.com/data"),
).toThrow(/does not match base/);
});

it("does not allow protocol-relative URLs to escape the origin", () => {
const url = resolveEndpointUrl(
"https://neptune:8182",
"//other-host.com/data",
);
expect(url.origin).toBe("https://neptune:8182");
});
});
41 changes: 20 additions & 21 deletions packages/graph-explorer-proxy-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@
const DEFAULT_SERVICE_TYPE = "neptune-db";

/**
* Resolves a relative endpoint path against a base URL, preserving the base
* path. Forces a trailing slash on the base so that `new URL` appends rather
* than replaces the path.
* Resolves an endpoint path against a base URL, preserving the base path.
* Strips any leading slash from the endpoint and forces a trailing slash on
* the base so that `new URL` appends rather than replaces the path.
*
* Throws if the resolved URL escapes the base origin (prevents SSRF via
* crafted endpoint strings).
*/
function resolveEndpointUrl<T extends string>(
base: string,
endpoint: T extends `/${string}` ? never : T,
): URL {
return new URL(endpoint, base.replace(/\/?$/, "/"));
export function resolveEndpointUrl(base: string, endpoint: string): URL {
const relative = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
const baseUrl = new URL(base.replace(/\/?$/, "/"));
const resolved = new URL(relative, baseUrl);
if (resolved.origin !== baseUrl.origin) {
throw new Error(
`Resolved URL origin "${resolved.origin}" does not match base "${baseUrl.origin}"`,
);
}
return resolved;
}

/** Zod schema for the custom headers expected on database query requests. */
Expand Down Expand Up @@ -184,7 +192,7 @@
};

try {
const res = await fetch(url.href, options);
const res = await fetch(url.href, options); // lgtm[js/request-forgery]

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
if (!res.ok) {
logger.error("!!Request failure!!");
return res;
Expand Down Expand Up @@ -490,10 +498,7 @@
app.get("/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"summary?mode=detailed",
).href;
const rawUrl = resolveEndpointUrl(graphDbConnectionUrl, req.url).href;

await fetchData(
res,
Expand All @@ -510,10 +515,7 @@
app.get("/pg/statistics/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"pg/statistics/summary?mode=detailed",
).href;
const rawUrl = resolveEndpointUrl(graphDbConnectionUrl, req.url).href;

await fetchData(
res,
Expand All @@ -530,10 +532,7 @@
app.get("/rdf/statistics/summary", async (req, res, next) => {
const { graphDbConnectionUrl, isIamEnabled, region, serviceType } =
parseDbQueryHeaders(req.headers);
const rawUrl = resolveEndpointUrl(
graphDbConnectionUrl,
"rdf/statistics/summary?mode=detailed",
).href;
const rawUrl = resolveEndpointUrl(graphDbConnectionUrl, req.url).href;

await fetchData(
res,
Expand Down
103 changes: 103 additions & 0 deletions packages/graph-explorer/src/connector/gremlin/gremlinExplorer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { FeatureFlags, NormalizedConnection } from "@/core";

import { createGremlinExplorer } from "./gremlinExplorer";

function createConnection(
overrides?: Partial<NormalizedConnection>,
): NormalizedConnection {
return {
url: "http://localhost:8182",
queryEngine: "gremlin",
graphDbUrl: "",
proxyConnection: false,
awsAuthEnabled: false,
...overrides,
};
}

function createFeatureFlags(): FeatureFlags {
return {
showDebugActions: false,
allowLoggingDbQuery: false,
};
}

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

const emptyGremlinList = {
result: {
data: { "@type": "g:List", "@value": [{ "@type": "g:Map", "@value": [] }] },
},
};

describe("createGremlinExplorer", () => {
let mockFetch: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("fetchSchema", () => {
it("requests the summary API with mode=basic", async () => {
const summaryResponse = {
payload: {
graphSummary: {
numNodes: 10,
numEdges: 5,
nodeLabels: ["Person"],
edgeLabels: ["knows"],
},
},
};
mockFetch
.mockResolvedValueOnce(jsonResponse(summaryResponse))
.mockImplementation(() =>
Promise.resolve(jsonResponse(emptyGremlinList)),
);

const explorer = createGremlinExplorer(
createConnection(),
createFeatureFlags(),
);
await explorer.fetchSchema();

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8182/pg/statistics/summary?mode=basic",
expect.objectContaining({ method: "GET" }),
);
});

it("falls back to query-based discovery when summary API fails", async () => {
mockFetch
.mockResolvedValueOnce(
new Response("Not Found", {
status: 404,
headers: { "Content-Type": "text/plain" },
}),
)
.mockImplementation(() =>
Promise.resolve(jsonResponse(emptyGremlinList)),
);

const explorer = createGremlinExplorer(
createConnection(),
createFeatureFlags(),
);
const schema = await explorer.fetchSchema();

expect(schema).toBeDefined();
expect(schema).toHaveProperty("vertices");
expect(schema).toHaveProperty("edges");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function fetchSummary(
const response = await fetchDatabaseRequest(
connection,
featureFlags,
`${connection.url}/pg/statistics/summary?mode=detailed`,
`${connection.url}/pg/statistics/summary?mode=basic`,
{
method: "GET",
...options,
Expand Down
Loading
Loading