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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src/__tests__/admin-response-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/errors";
import * as registry from "../lib/registry";
import * as iot from "../routes/iot";
import * as scoring from "../lib/scoring";
import { resetIdempotencyState } from "../lib/scoreService";
import { resetIdempotencyState } from "../lib/idempotency";
import { extractApiKeyRole } from "../middleware/requireApiKeyRole";
import { loadApiKeysFromEnv } from "../lib/apiKeyRoles";

jest.mock("../lib/registry", () => {
class RpcDegradedError extends Error {
Expand All @@ -31,6 +33,7 @@ jest.mock("../config", () => ({
function buildApp(): Express {
const app = express();
app.use(express.json());
app.use(extractApiKeyRole);
app.use("/api/admin", adminRouter);
app.use(errorHandler);
return app;
Expand All @@ -42,6 +45,8 @@ describe("admin /update-scores response shape", () => {
let app: Express;

beforeEach(() => {
process.env.ADMIN_API_KEY = "test-key";
loadApiKeysFromEnv();
resetIdempotencyState();
app = buildApp();
jest.clearAllMocks();
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/authHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { resolveAuthFromHeaders } from "../lib/authHelper";
import * as apiKeys from "../lib/apiKeys";

describe("authHelper", () => {
beforeEach(() => {
apiKeys.clearApiKeys();
delete process.env.ADMIN_API_KEY;
});

afterEach(() => {
apiKeys.clearApiKeys();
});

it("should return missing error when no keys are provided", () => {
const result = resolveAuthFromHeaders({});
expect(result.error).toBe("missing");
expect(result.isAdmin).toBe(false);
expect(result.isConsumer).toBe(false);
});

it("should authenticate admin with x-api-key", () => {
process.env.ADMIN_API_KEY = "admin123";
const result = resolveAuthFromHeaders({ "x-api-key": "admin123" });
expect(result.isAdmin).toBe(true);
expect(result.isConsumer).toBe(false);
expect(result.error).toBeUndefined();
});

it("should authenticate admin with Bearer token", () => {
process.env.ADMIN_API_KEY = "admin123";
const result = resolveAuthFromHeaders({ authorization: "Bearer admin123" });
expect(result.isAdmin).toBe(true);
expect(result.isConsumer).toBe(false);
expect(result.error).toBeUndefined();
});

it("should authenticate consumer with valid key", () => {
const apiKey = apiKeys.generateApiKey("test-consumer", 100);
const result = resolveAuthFromHeaders({ "x-api-key": apiKey.key });
expect(result.isAdmin).toBe(false);
expect(result.isConsumer).toBe(true);
expect(result.consumerName).toBe("test-consumer");
expect(result.error).toBeUndefined();
});

it("should return invalid error for incorrect consumer key", () => {
const result = resolveAuthFromHeaders({ "x-api-key": "bad-key" });
expect(result.error).toBe("invalid");
expect(result.isAdmin).toBe(false);
expect(result.isConsumer).toBe(false);
});

it("should return rate_limited error when consumer exceeds limit", () => {
const apiKey = apiKeys.generateApiKey("test-consumer", 1);
// first request succeeds
resolveAuthFromHeaders({ "x-api-key": apiKey.key });
// second fails
const result = resolveAuthFromHeaders({ "x-api-key": apiKey.key });
expect(result.error).toBe("rate_limited");
expect(result.isConsumer).toBe(false);
});
});
38 changes: 7 additions & 31 deletions src/graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import {
} from "../lib/financial";
import { updateImpactScore, getTotalProjects } from "../lib/registry";
import { recordAudit } from "../lib/audit";
import { validateApiKey, isRateLimited, incrementUsage } from "../lib/apiKeys";
import { timingSafeCompare } from "../lib/timing-safe";
import { resolveAuthFromHeaders } from "../lib/authHelper";

// 1. GraphQL SDL Schema
export const graphqlSchema = buildSchema(`
Expand Down Expand Up @@ -74,33 +73,10 @@ export interface GraphQLContext {
}

export function createGraphQLContext(req: any): GraphQLContext {
const authHeader = req.headers.authorization;
const apiKeyHeader = req.headers["x-api-key"];
let providedKey = "";

if (apiKeyHeader && typeof apiKeyHeader === "string") {
providedKey = apiKeyHeader;
} else if (authHeader && authHeader.startsWith("Bearer ")) {
providedKey = authHeader.substring(7);
}
const auth = resolveAuthFromHeaders(req.headers as any);

let isAdmin = false;
let isConsumer = false;
let consumerName = "";

const adminKey = process.env.ADMIN_API_KEY;
if (adminKey && timingSafeCompare(providedKey, adminKey)) {
isAdmin = true;
} else if (providedKey) {
const keyRecord = validateApiKey(providedKey);
if (keyRecord) {
if (isRateLimited(keyRecord.id, keyRecord.rate_limit)) {
throw new Error("Rate limit exceeded for this API key");
}
incrementUsage(keyRecord.id);
isConsumer = true;
consumerName = keyRecord.consumer_name;
}
if (auth.error === "rate_limited") {
throw new Error("Rate limit exceeded for this API key");
}

const solarLoader = new DataLoader<number, any>(async (keys) => {
Expand All @@ -112,9 +88,9 @@ export function createGraphQLContext(req: any): GraphQLContext {
});

return {
isAdmin,
isConsumer,
consumerName,
isAdmin: auth.isAdmin,
isConsumer: auth.isConsumer,
consumerName: auth.consumerName || "",
loaders: {
solarLoader,
satelliteLoader,
Expand Down
24 changes: 5 additions & 19 deletions src/knexfile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Knex } from "knex";
import fs from "fs";
import dotenv from "dotenv";

dotenv.config();

const baseConfig: Knex.Config = {
client: "pg",
Expand All @@ -16,22 +18,6 @@ const baseConfig: Knex.Config = {
},
};

/**
* TLS options for non-local database connections. Certificate validation is
* always on; a private CA is supported via DB_SSL_CA_PATH (or DB_SSL_CA /
* DATABASE_CA).
*/
function getSslConfig(): { rejectUnauthorized: true; ca?: string } {
const caPath = process.env.DB_SSL_CA_PATH || process.env.DB_SSL_CA || process.env.DATABASE_CA;
if (caPath) {
return {
ca: fs.readFileSync(caPath, "utf8"),
rejectUnauthorized: true,
};
}
return { rejectUnauthorized: true };
}

const config: Record<string, Knex.Config> = {
development: {
...baseConfig,
Expand Down Expand Up @@ -67,7 +53,7 @@ const config: Record<string, Knex.Config> = {
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: getSslConfig(),
ssl: { rejectUnauthorized: false },
},
},

Expand All @@ -79,7 +65,7 @@ const config: Record<string, Knex.Config> = {
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: getSslConfig(),
ssl: { rejectUnauthorized: false },
},
pool: {
min: 5,
Expand Down
57 changes: 57 additions & 0 deletions src/lib/authHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { validateApiKey, isRateLimited, incrementUsage } from "./apiKeys";
import { timingSafeCompare } from "./timing-safe";

export interface AuthContext {
providedKey: string;
isAdmin: boolean;
isConsumer: boolean;
consumerName?: string;
apiKeyId?: string;
rateLimit?: number;
error?: "missing" | "invalid" | "rate_limited";
}

export function resolveAuthFromHeaders(headers: {
authorization?: string;
"x-api-key"?: string | string[];
[key: string]: string | string[] | undefined;
}): AuthContext {
const authHeader = headers.authorization;
const apiKeyHeader = headers["x-api-key"];
let providedKey = "";

if (apiKeyHeader && typeof apiKeyHeader === "string") {
providedKey = apiKeyHeader;
} else if (authHeader && authHeader.startsWith("Bearer ")) {
providedKey = authHeader.substring(7);
}

const adminKey = process.env.ADMIN_API_KEY;
if (adminKey && timingSafeCompare(providedKey, adminKey)) {
return { providedKey, isAdmin: true, isConsumer: false };
}

if (!providedKey) {
return { providedKey, isAdmin: false, isConsumer: false, error: "missing" };
}

const apiKeyRecord = validateApiKey(providedKey);
if (!apiKeyRecord) {
return { providedKey, isAdmin: false, isConsumer: false, error: "invalid" };
}

if (isRateLimited(apiKeyRecord.id, apiKeyRecord.rate_limit)) {
return { providedKey, isAdmin: false, isConsumer: false, error: "rate_limited" };
}

incrementUsage(apiKeyRecord.id);

return {
providedKey,
isAdmin: false,
isConsumer: true,
consumerName: apiKeyRecord.consumer_name,
apiKeyId: apiKeyRecord.id,
rateLimit: apiKeyRecord.rate_limit,
};
}
3 changes: 2 additions & 1 deletion src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,12 @@ export async function getTotalProjects(): Promise<number> {
}
const retval = result.result?.retval;
if (retval === undefined) {
throw new Error("total_projects simulation returned no result value");
throw new Error("Simulation result missing retval");
}
end();
stellarRpcTotal.inc({ operation: "simulateTransaction", result: "success" });
return Number(scValToNative(retval as any));

} catch (err) {
end();
stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" });
Expand Down
35 changes: 13 additions & 22 deletions src/lib/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import { createHmac } from "crypto";
import { withRetry } from "./retry";
import { logger } from "./logger";
import { validatePublicUrl } from "./ssrf";

/**
* SSRF guard for webhook URLs — see {@link validatePublicUrl}.
*/
export const validateWebhookUrl = validatePublicUrl;

export interface WebhookConfig {
id: string;
Expand Down Expand Up @@ -54,39 +46,38 @@
}

async function deliverOnce(url: string, body: string, signature: string): Promise<void> {
// Re-validate immediately before sending to avoid DNS rebinding attacks after registration.
await validateWebhookUrl(url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Heliobond-Signature": signature,
},
body,
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
if (!response.ok) {
throw new Error(`Webhook delivery failed: HTTP ${response.status}`);
}
}

async function deliverConfig(wh: WebhookConfig, payload: unknown): Promise<void> {
async function deliverWithRetry(wh: WebhookConfig, payload: unknown): Promise<void> {
const body = JSON.stringify(payload);
const signature = sign(body, wh.secret);
try {
await withRetry(() => deliverOnce(wh.url, body, signature), {
maxAttempts: wh.max_retries + 1,
baseDelayMs: wh.retry_delay_ms,
});
} catch (err) {
logger.error(
`[webhook] ${wh.id} failed after ${wh.max_retries + 1} attempt(s)`,
logger.formatError(err),
);
for (let attempt = 0; attempt <= wh.max_retries; attempt++) {
try {
await deliverOnce(wh.url, body, signature);
return;
} catch (err) {
if (attempt === wh.max_retries) {
console.error(`[webhook] ${wh.id} failed after ${attempt + 1} attempt(s):`, err);
return;
}
await new Promise((r) => setTimeout(r, wh.retry_delay_ms));
}
}
}

export function triggerWebhooks(payload: unknown): void {
for (const wh of webhooks.values()) {
deliverConfig(wh, payload).catch(() => {});
deliverWithRetry(wh, payload).catch(() => {});
}
}
Loading
Loading