Skip to content

Commit

Permalink
Feat/server hardening (#4)
Browse files Browse the repository at this point in the history
* adds rate limiting on all routes, adds redis and related config for rate limiter

* adds first route tests and server test helper, adds isPubKey validator

* fixes ledger key generator for token balance, adds mode env var

* adds test for account balances route

* updates balance key to use symbol instead of string

* updates test ledger key
  • Loading branch information
aristidesstaffieri authored Oct 30, 2023
1 parent b142c0e commit 8d450a0
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 57 deletions.
6 changes: 5 additions & 1 deletion .env-EXAMPLE
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ AUTH_PASS=not-set
MERCURY_USER_ID=not-set
MERCURY_KEY=not-set
MERCURY_BACKEND=not-set
MERCURY_GRAPHQL=not-set
MERCURY_GRAPHQL=not-set
REDIS_CONNECTION_NAME=not-set
REDIS_PORT=not-set
HOSTNAME=not-set
MODE=not-set
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ You will need

## Development

This application relies on a Redis instance, you can either run `docker compose up` to use docker to stand up a Redis or you can start one on the standard port manually.

To start the server in development mode, run:
`yarn i && yarn start`

## Production build
Expand Down
15 changes: 15 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: "3.9"
services:
redis:
image: redis:7.2-alpine
container_name: freighter-redis
hostname: freighter-redis
restart: always
networks:
- freighter
ports:
- 6379:6379

networks:
freighter:
driver: bridge
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@
},
"dependencies": {
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^8.0.3",
"@urql/core": "^4.1.3",
"ajv": "^8.12.0",
"axios": "^1.5.1",
"dotenv-expand": "^10.0.0",
"fastify": "^4.23.2",
"ioredis": "^5.3.2",
"pino": "^8.15.3",
"pino-pretty": "^10.2.0",
"soroban-client": "^1.0.0-beta.2",
Expand Down
18 changes: 13 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
const ENV_KEYS = [
"MERCURY_KEY",
"AUTH_EMAIL",
"AUTH_PASS",
"HOSTNAME",
"MERCURY_BACKEND",
"MERCURY_GRAPHQL",
"MERCURY_KEY",
"MERCURY_USER_ID",
"AUTH_EMAIL",
"AUTH_PASS",
"MODE",
"REDIS_CONNECTION_NAME",
"REDIS_PORT",
];

export function buildConfig(config: Record<string, string>) {
Expand All @@ -15,12 +19,16 @@ export function buildConfig(config: Record<string, string>) {
});

return {
hostname: config.HOSTNAME,
mercuryBackend: config.MERCURY_BACKEND,
mercuryEmail: config.AUTH_EMAIL,
mercuryGraphQL: config.MERCURY_GRAPHQL,
mercuryKey: config.MERCURY_KEY,
mercuryPassword: config.AUTH_PASS,
mercuryBackend: config.MERCURY_BACKEND,
mercuryGraphQL: config.MERCURY_GRAPHQL,
mercuryUserId: config.MERCURY_USER_ID,
mode: config.MODE,
redisConnectionName: config.REDIS_CONNECTION_NAME,
redisPort: Number(config.REDIS_PORT),
};
}

Expand Down
51 changes: 42 additions & 9 deletions src/helper/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import pino from "pino";

import { mutation, query } from "../service/mercury/queries";
import { MercuryClient } from "../service/mercury";
import { initApiServer } from "../route";

const testLogger = pino({
name: "test-logger",
Expand Down Expand Up @@ -37,9 +38,9 @@ const mercurySession = {
userId: "1",
};

const pubKey = "GDUBMXMABE7UOZSGYJ5ONE7UYAEHKK3JOX7HZQGNZ7NYTZPPP4AJ2GQJ";
const pubKey = "GCGORBD5DB4JDIKVIA536CJE3EWMWZ6KBUBWZWRQM7Y3NHFRCLOKYVAL";
const tokenBalanceLedgerKey =
"AAAAEAAAAAEAAAABAAAAEQAAAAEAAAACAAAADwAAAAdiYWxhbmNlAAAAAA4AAAAHQmFsYW5jZQAAAAAPAAAAB2FkZHJlc3MAAAAADgAAADhHRFVCTVhNQUJFN1VPWlNHWUo1T05FN1VZQUVIS0szSk9YN0haUUdOWjdOWVRaUFBQNEFKMkdRSg==";
"AAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtys";

const queryMockResponse = {
[mutation.authenticate]: {
Expand All @@ -59,7 +60,8 @@ const queryMockResponse = {
edges: [
{
node: {
contractId: "contract-id-1",
contractId:
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
keyXdr: tokenBalanceLedgerKey,
valueXdr: "value-xdr",
ledgerTimestamp: "timestamp",
Expand All @@ -69,7 +71,8 @@ const queryMockResponse = {
},
{
node: {
contractId: "contract-id-2",
contractId:
"CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG",
keyXdr: tokenBalanceLedgerKey,
valueXdr: "value-xdr",
ledgerTimestamp: "timestamp",
Expand Down Expand Up @@ -100,7 +103,7 @@ const queryMockResponse = {
assetNative: true,
accountBySource: {
publickey:
"GCGORBD5DB4JDIKVIA536CJE3EWMWZ6KBUBWZWRQM7Y3NHFRCLOKYVAL",
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
},
accountByDestination: {
publickey: pubKey,
Expand Down Expand Up @@ -133,8 +136,16 @@ jest.spyOn(client, "query").mockImplementation((_query: any): any => {
});
}
case query.getAccountBalances(tokenBalanceLedgerKey, [
"contract-id-1",
"contract-id-2",
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
]): {
return Promise.resolve({
data: queryMockResponse["query.getAccountBalances"],
error: null,
});
}
case query.getAccountBalances(tokenBalanceLedgerKey, [
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
"CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG",
]): {
return Promise.resolve({
data: queryMockResponse["query.getAccountBalances"],
Expand All @@ -147,5 +158,27 @@ jest.spyOn(client, "query").mockImplementation((_query: any): any => {
});

const mockMercuryClient = new MercuryClient(mercurySession, client, testLogger);

export { pubKey, mockMercuryClient, queryMockResponse };
async function getDevServer() {
const config = {
hostname: "localhost",
mode: "development",
mercuryEmail: "[email protected]",
mercuryKey: "xxx",
mercuryPassword: "pass",
mercuryBackend: "backend",
mercuryGraphQL: "graph-ql",
mercuryUserId: "user-id",
redisConnectionName: "freighter",
redisPort: 6379,
};
const server = initApiServer(mockMercuryClient, config);
await server.listen();
return server;
}
export {
pubKey,
mockMercuryClient,
queryMockResponse,
getDevServer,
tokenBalanceLedgerKey,
};
11 changes: 10 additions & 1 deletion src/helper/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@ const isContractId = (contractId: string) => {
}
};

export { isContractId };
const isPubKey = (pubKey: string) => {
try {
StrKey.decodeEd25519PublicKey(pubKey);
return true;
} catch (error) {
return false;
}
};

export { isContractId, isPubKey };
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async function main() {
userId: conf.mercuryUserId,
};
const mercuryClient = new MercuryClient(mercurySession, client, logger);
const server = initApiServer(mercuryClient);
const server = initApiServer(mercuryClient, conf);

try {
await server.listen({ port });
Expand Down
114 changes: 114 additions & 0 deletions src/route/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { getDevServer, queryMockResponse, pubKey } from "../helper/test-helper";
import { query } from "../service/mercury/queries";

describe("API routes", () => {
describe("/account-history/:pubKey", () => {
it("can fetch an account history for a pub key", async () => {
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-history/${pubKey}`
);
const { data } = await response.json();
expect(response.status).toEqual(200);
expect(data).toMatchObject(queryMockResponse[query.getAccountHistory]);
server.close();
});

it("rejects requests for non strings that are not pub keys", async () => {
const notPubkey = "newp";
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-history/${notPubkey}`
);
expect(response.status).toEqual(400);
server.close();
});
});

describe("/account-balances/:pubKey", () => {
it("can fetch account balances for a pub key & contract IDs", async () => {
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-balances/${pubKey}?contract_ids=CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP`
);
const { data } = await response.json();
expect(response.status).toEqual(200);
expect(data.edges).toEqual(
queryMockResponse["query.getAccountBalances"].edges
);
server.close();
});

it("can fetch account balances for a pub key & multiple contract IDs", async () => {
const params = {
contract_ids: [
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
"CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG",
],
};
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-balances/${pubKey}?${new URLSearchParams(
params as any
)}`
);
const { data } = await response.json();
expect(response.status).toEqual(200);
expect(data.edges).toEqual(
queryMockResponse["query.getAccountBalances"].edges
);
server.close();
});

it("rejects if any contract ID is not valid", async () => {
const params = {
contract_ids: [
"CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
"newp",
],
};
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-balances/${pubKey}?${new URLSearchParams(
params as any
)}`
);
expect(response.status).toEqual(400);
server.close();
});

it("rejects requests for non strings that are not pub keys", async () => {
const notPubkey = "newp";
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-balances/${notPubkey}?contract_ids=CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP`
);
expect(response.status).toEqual(400);
server.close();
});

it("rejects requests with bad contract IDs query param", async () => {
const notContractId = "newp";
const server = await getDevServer();
const response = await fetch(
`http://localhost:${
(server?.server?.address() as any).port
}/api/v1/account-balances/${pubKey}?contract_ids=${notContractId}`
);
expect(response.status).toEqual(400);
server.close();
});
});
});
Loading

0 comments on commit 8d450a0

Please sign in to comment.