Skip to content
Draft
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
25 changes: 25 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Deploy: ExplorerToken backend",
"type": "shell",
"command": "bash ~/.deploy/deploy-backend.sh",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "Deploy: ExplorerToken frontend",
"type": "shell",
"command": "bash ~/.deploy/deploy-frontend.sh",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
}
]
}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,37 @@ Deployment is handled via GitHub Actions to a VPS using Docker Compose. See the
- **Database**: PostgreSQL container with backup scripts
- **Cache**: Redis container (optional)

### Deploy from VS Code (Remote-SSH)

When connected to the production server via VS Code Remote-SSH, you can use one-click deployment tasks:

**Backend Deploy Task** (`Deploy: ExplorerToken backend`)
- Builds the backend with `npm ci && npm run build`
- Restarts the backend service
- Runs database migrations if needed
- **Success signals**: Service status shows "active (running)", backend API responds with 200

**Frontend Deploy Task** (`Deploy: ExplorerToken frontend`)
- Builds the frontend with `npm ci && npm run build`
- Detects the Nginx web root for haswork.dev automatically
- Backs up current deployment to `{root}_backup_{timestamp}`
- Deploys new build with rsync
- Runs sanity checks:
- `curl -I https://haswork.dev` → HTTP/2 200
- `curl -I https://haswork.dev/api/chains` → HTTP/2 200
- **Success signals**: Both curl checks return 200, no 500 errors

**Security & Admin Route Checks**
- Unauthenticated `HEAD /api/admin/settings` → 401/403 (not 500)
- Rate limiting in effect:
- `/api/auth/login`: 5 requests/min per IP, returns `{ "error": "rate_limited" }` when exceeded
- `/api/admin/*`: 60 requests/min per IP, returns `{ "error": "rate_limited" }` when exceeded

To run a deployment task:
1. Open Command Palette (Ctrl+Shift+P / Cmd+Shift+P)
2. Type "Tasks: Run Task"
3. Select either "Deploy: ExplorerToken backend" or "Deploy: ExplorerToken frontend"

## Contributing

1. Create a focused branch for your changes
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"morgan": "^1.10.1",
"node-cache": "^5.1.2",
"pg": "^8.16.3",
"rate-limit-redis": "^4.2.3",
"redis": "^4.7.0",
"zod": "^4.1.12"
},
Expand Down
4 changes: 2 additions & 2 deletions backend/src/config/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,6 @@ export const SUPPORTED_CHAINS: ChainMeta[] = KNOWN_CHAINS.filter((c) => c.suppor

/**
* Default chain IDs to use when no chains are configured
* Includes the 10 primary supported chains
* Includes the 9 APIV2-supported chains (Linea excluded pending full vendor support)
*/
export const DEFAULT_CHAIN_IDS: number[] = [1, 56, 137, 43114, 8453, 324, 42161, 10, 5000, 59144];
export const DEFAULT_CHAIN_IDS: number[] = [1, 10, 56, 137, 42161, 43114, 8453, 324, 5000];
69 changes: 50 additions & 19 deletions backend/src/middleware/rateLimiters.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,69 @@
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import * as redis from '@/services/redis';

const isTest = process.env.NODE_ENV === 'test';
const WINDOW_MS = Number(process.env.RATE_WINDOW_MS ?? 60_000);
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const LOGIN_MAX = Number(process.env.LOGIN_MAX ?? (isTest ? 100000 : 10));
const ADMIN_READ_MAX = Number(process.env.ADMIN_READ_MAX ?? (isTest ? 100000 : 120));
const ADMIN_WRITE_MAX = Number(process.env.ADMIN_WRITE_MAX ?? (isTest ? 100000 : 30));
const LOGIN_WINDOW_MS = 60 * 1000; // 1 minute
const LOGIN_MAX = Number(process.env.LOGIN_MAX ?? (isTest ? 100000 : 5));
const ADMIN_WINDOW_MS = 60 * 1000; // 1 minute
const ADMIN_MAX = Number(process.env.ADMIN_MAX ?? (isTest ? 100000 : 60));

/** Limit brute-force on login specifically */
/**
* Create a Redis store for rate limiting if Redis is available
* Returns undefined to use in-memory store as fallback
*/
function getRedisStore() {
try {
const client = redis.getClient();
if (client?.isOpen) {
return new RedisStore({
sendCommand: (...args: string[]) => client.sendCommand(args),
});
}
} catch (error) {
// Redis not available, will use in-memory store
}
return undefined;
}

/** Limit brute-force on login: 5/min per IP (burst up to 10) */
export const loginLimiter = rateLimit({
windowMs: LOGIN_WINDOW_MS,
max: LOGIN_MAX,
skipSuccessfulRequests: false,
skipFailedRequests: false,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts, please try again later.' },
handler: (req, res) => {
res.set('Retry-After', String(Math.ceil(LOGIN_WINDOW_MS / 1000))); // Convert to seconds
res.status(429).json({ error: 'Too many login attempts, please try again later.' });
store: getRedisStore(),
handler: (_req, res) => {
res.status(429).json({ error: 'rate_limited' });
},
});

/** Admin reads (settings) */
export const adminReadLimiter = rateLimit({
windowMs: WINDOW_MS,
max: ADMIN_READ_MAX,
/** Admin route rate limiter: 60/min per IP */
export const adminLimiter = rateLimit({
windowMs: ADMIN_WINDOW_MS,
max: ADMIN_MAX,
skipSuccessfulRequests: false,
skipFailedRequests: false,
standardHeaders: true,
legacyHeaders: false,
store: getRedisStore(),
handler: (_req, res) => {
res.status(429).json({ error: 'rate_limited' });
},
});

/** Admin writes (apikey/settings/cache clear) */
export const adminWriteLimiter = rateLimit({
windowMs: WINDOW_MS,
max: ADMIN_WRITE_MAX,
/** General auth endpoint rate limiter: 30/min per IP for /me and similar endpoints */
export const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: Number(process.env.AUTH_MAX ?? (isTest ? 100000 : 30)),
skipSuccessfulRequests: false,
skipFailedRequests: false,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many admin requests, slow down.' },
store: getRedisStore(),
handler: (_req, res) => {
res.status(429).json({ error: 'rate_limited' });
},
});
6 changes: 3 additions & 3 deletions backend/src/routes/__tests__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ describe('Admin Routes', () => {
chainsDetailed: expect.any(Array),
});

// Verify we got the default chains
expect(response.body.selectedChainIds.length).toBe(10);
expect(response.body.chainsDetailed.length).toBe(10);
// Verify we got the default chains (9 APIV2-supported chains)
expect(response.body.selectedChainIds.length).toBe(9);
expect(response.body.chainsDetailed.length).toBe(9);
});

it('should return 500 with requestId on DB error', async () => {
Expand Down
207 changes: 207 additions & 0 deletions backend/src/routes/__tests__/rate-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Set up environment variables before importing modules
process.env.ETHERSCAN_API_KEY = 'test-api-key';
process.env.JWT_SECRET = 'test-jwt-secret-minimum-16-chars';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.NODE_ENV = 'production'; // Use production limits for this test
process.env.LOGIN_MAX = '5'; // 5 requests per minute for login
process.env.ADMIN_MAX = '60'; // 60 requests per minute for admin

import express, { Express } from 'express';
import request from 'supertest';
import { authRouter } from '../auth';
import { adminRouter } from '../admin';
import * as auth from '@/services/auth';
import * as db from '@/services/db';
import { RequestWithId } from '@/middleware/requestId';

// Mock services
jest.mock('@/services/auth');
jest.mock('@/services/db');
jest.mock('@/services/settings');
jest.mock('@/services/cache');
jest.mock('../explorer', () => ({
flushUsageLogs: jest.fn().mockReturnValue(new Map()),
}));

describe('Rate Limiting', () => {
let app: Express;
let mockDbQuery: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();

// Mock DB query
mockDbQuery = jest.fn();
(db.getDb as jest.Mock).mockReturnValue({
query: mockDbQuery,
});
});

describe('HEAD /api/admin/settings without auth', () => {
beforeEach(() => {
// Create a fresh app for each test to avoid rate limit carryover
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as RequestWithId).requestId = 'test-request-id';
next();
});
app.use('/api/auth', authRouter);
app.use('/api/admin', adminRouter);
});

it('should return 401, not 500, for unauthenticated HEAD request', async () => {
const response = await request(app).head('/api/admin/settings').expect(401);
expect(response.status).toBe(401);
});

it('should return 401 for HEAD request with invalid token', async () => {
(auth.verifyToken as jest.Mock).mockImplementation(() => {
throw new Error('Invalid token');
});

const response = await request(app)
.head('/api/admin/settings')
.set('Authorization', 'Bearer invalid')
.expect(401);

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

describe('POST /api/auth/login rate limiting', () => {
beforeEach(() => {
// Create a fresh app for each test suite
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as RequestWithId).requestId = 'test-request-id';
next();
});
app.use('/api/auth', authRouter);
app.use('/api/admin', adminRouter);
});
it('should return 429 with rate_limited error when exceeding 5 requests per minute', async () => {
// Mock authenticateUser to return null (failed login)
(auth.authenticateUser as jest.Mock).mockResolvedValue(null);

// Make 5 requests (should succeed)
for (let i = 0; i < 5; i++) {
await request(app)
.post('/api/auth/login')
.send({ username: 'test', password: 'test' })
.expect(401); // Invalid credentials
}

// 6th request should be rate limited
const response = await request(app)
.post('/api/auth/login')
.send({ username: 'test', password: 'test' })
.expect(429);

expect(response.body).toEqual({ error: 'rate_limited' });
});

it('should return 429 JSON response with correct format', async () => {
(auth.authenticateUser as jest.Mock).mockResolvedValue(null);

// Exhaust the rate limit
for (let i = 0; i < 5; i++) {
await request(app).post('/api/auth/login').send({ username: 'test', password: 'test' });
}

// Next request should be rate limited
const response = await request(app)
.post('/api/auth/login')
.send({ username: 'test', password: 'test' })
.expect(429);

expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('rate_limited');
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-remaining');
});
});

describe('GET /api/admin/settings rate limiting', () => {
beforeEach(() => {
// Create a fresh app for each test suite
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as RequestWithId).requestId = 'test-request-id';
next();
});
app.use('/api/auth', authRouter);
app.use('/api/admin', adminRouter);
});
it('should return 429 with rate_limited error when exceeding 60 requests per minute', async () => {
// Mock valid token
(auth.verifyToken as jest.Mock).mockReturnValue({
userId: 'test-id',
username: 'admin',
role: 'admin',
});

mockDbQuery.mockResolvedValue({
rows: [
{
id: 1,
chains: [1, 137],
cache_ttl: 60,
etherscan_api_key: 'key',
},
],
});

const token = 'valid-token';

// Make many requests until we hit the rate limit
let hitLimit = false;
for (let i = 0; i < 70; i++) {
const response = await request(app)
.get('/api/admin/settings')
.set('Authorization', `Bearer ${token}`);

if (response.status === 429) {
hitLimit = true;
expect(response.body).toEqual({ error: 'rate_limited' });
break;
}
}

expect(hitLimit).toBe(true);
});

it('should apply rate limit to all admin routes', async () => {
// Mock valid token
(auth.verifyToken as jest.Mock).mockReturnValue({
userId: 'test-id',
username: 'admin',
role: 'admin',
});

mockDbQuery.mockResolvedValue({
rows: [{ id: 1, chains: [1], cache_ttl: 60, etherscan_api_key: 'key' }],
});

const token = 'valid-token';

// Make 60 requests across different admin endpoints
for (let i = 0; i < 30; i++) {
await request(app).get('/api/admin/settings').set('Authorization', `Bearer ${token}`);
}
for (let i = 0; i < 30; i++) {
await request(app).get('/api/admin/metrics').set('Authorization', `Bearer ${token}`);
}

// Next request to any admin endpoint should be rate limited
const response = await request(app)
.get('/api/admin/settings')
.set('Authorization', `Bearer ${token}`)
.expect(429);

expect(response.body).toEqual({ error: 'rate_limited' });
});
});
});
Loading
Loading