Skip to content

Commit 43fc865

Browse files
authored
merge: feat(security): Centralized Middleware for Authentication and Rate Limiting (#8254)
## Description Fixes #7437 Introduces a centralized, configurable middleware layer to handle API authentication, authorization, rate-limiting, and security headers globally. Redundant manual security checks have been removed from route handlers. Key changes: - Created a route rules registry inside `middleware.ts` to manage route-specific auth levels and rate limits. - Configured NextAuth session checks and enterprise admin role checks within the centralized middleware runtime. - Standardized security headers across all responses and integrated robust error handling for unauthorized/forbidden paths. - Removed duplicated authentication code from `/api/enterprise` and `/api/architecture` routes. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview None (Backend/Security refactoring) ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [ ] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [ ] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents a14caf9 + 3635a1a commit 43fc865

13 files changed

Lines changed: 370 additions & 154 deletions

app/api/architecture/route.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,10 @@ import pLimit from 'p-limit';
99
import { cloneGitHubRepository } from '@/lib/git-clone';
1010
import { getGitHubTokens } from '@/lib/github';
1111
import { formatRepoRefForLogging, sanitizeErrorForLogging } from '@/lib/sanitize-git-credentials';
12-
import { auth } from '@/auth';
1312
import { getClientIp } from '@/utils/getClientIp';
1413

1514
const execFilePromise = promisify(execFile);
1615

17-
const REST_TIMEOUT_MS = 5000; // 5s timeout for external API requests
18-
1916
// Per-IP concurrent clone tracking (max 3 concurrent clones per IP)
2017
const MAX_CONCURRENT_CLONES_PER_IP = 3;
2118
const MAX_TEMP_DIR_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
@@ -310,12 +307,6 @@ export async function POST(req: NextRequest) {
310307
let tempDir = '';
311308
const ip = getClientIp(req);
312309

313-
// Require authenticated session
314-
const session = await auth();
315-
if (!session?.user) {
316-
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
317-
}
318-
319310
// Check concurrent clone limit per IP
320311
if (!incrementClones(ip)) {
321312
return NextResponse.json(

app/api/enterprise/route.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { aggregateTeamData } from '@/lib/analytics/teamHealth';
3-
import { requireEnterpriseAdmin } from '@/lib/enterprise-auth';
43
import type { TeamMember } from '@/types/enterprise';
54
import type { ContributionCalendar } from '@/types';
65

@@ -35,9 +34,6 @@ function createMockTeamMembers(usernames: string[]): TeamMember[] {
3534
}
3635

3736
export async function GET(request: NextRequest) {
38-
const { error } = await requireEnterpriseAdmin();
39-
if (error) return error;
40-
4137
const searchParams = request.nextUrl.searchParams;
4238
const teamId = searchParams.get('teamId') || 'default-team';
4339
const teamName = searchParams.get('teamName') || 'Engineering Team';
@@ -74,9 +70,6 @@ export async function GET(request: NextRequest) {
7470
}
7571

7672
export async function POST(request: NextRequest) {
77-
const { error } = await requireEnterpriseAdmin();
78-
if (error) return error;
79-
8073
let body: Record<string, unknown>;
8174
try {
8275
body = await request.json();

app/api/enterprise/teams/[teamId]/route.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { requireEnterpriseAdmin } from '@/lib/enterprise-auth';
32

43
export async function PATCH(
54
_request: NextRequest,
65
{ params }: { params: Promise<{ teamId: string }> }
76
) {
8-
const { error } = await requireEnterpriseAdmin();
9-
if (error) return error;
10-
117
const { teamId } = await params;
128

139
if (!teamId) {
@@ -24,9 +20,6 @@ export async function DELETE(
2420
_request: NextRequest,
2521
{ params }: { params: Promise<{ teamId: string }> }
2622
) {
27-
const { error } = await requireEnterpriseAdmin();
28-
if (error) return error;
29-
3023
const { teamId } = await params;
3124

3225
if (!teamId) {

app/api/enterprise/teams/route.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { requireEnterpriseAdmin } from '@/lib/enterprise-auth';
32

43
const mockTeams: Record<string, { id: string; name: string; members: string[] }> = {};
54

65
export async function GET() {
7-
const { error } = await requireEnterpriseAdmin();
8-
if (error) return error;
9-
106
const teams = Object.values(mockTeams);
117

128
return NextResponse.json({
@@ -16,9 +12,6 @@ export async function GET() {
1612
}
1713

1814
export async function POST(request: NextRequest) {
19-
const { error } = await requireEnterpriseAdmin();
20-
if (error) return error;
21-
2215
let body: { name?: string; members?: string[] };
2316
try {
2417
body = await request.json();

auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
2121
callbacks: {
2222
async jwt({ token, account }) {
2323
if (account?.access_token) {
24-
token.ghToken = encryptToken(account.access_token);
24+
token.ghToken = await encryptToken(account.access_token);
2525
}
2626
return token;
2727
},

lib/crypto.empty-fallback.test.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,75 +10,75 @@ afterEach(() => {
1010
});
1111

1212
describe('crypto empty / missing inputs verification', () => {
13-
it('encrypts and decrypts a normal token string', () => {
13+
it('encrypts and decrypts a normal token string', async () => {
1414
const plain = 'gho_abc123def456token';
15-
const encrypted = encryptToken(plain);
15+
const encrypted = await encryptToken(plain);
1616
expect(encrypted).toBeDefined();
1717
expect(encrypted).not.toBe(plain);
1818
expect(encrypted.split('.')).toHaveLength(4);
19-
expect(decryptToken(encrypted)).toBe(plain);
19+
expect(await decryptToken(encrypted)).toBe(plain);
2020
});
2121

22-
it('handles empty string encryption and decryption', () => {
23-
const encrypted = encryptToken('');
22+
it('handles empty string encryption and decryption', async () => {
23+
const encrypted = await encryptToken('');
2424
expect(encrypted.split('.')).toHaveLength(4);
25-
expect(decryptToken(encrypted)).toBe('');
25+
expect(await decryptToken(encrypted)).toBe('');
2626
});
2727

28-
it('rejects malformed payload with wrong number of parts', () => {
29-
expect(() => decryptToken('only-one-part')).toThrow();
30-
expect(() => decryptToken('two.parts')).toThrow();
31-
expect(() => decryptToken('a.b.c.d')).toThrow();
28+
it('rejects malformed payload with wrong number of parts', async () => {
29+
await expect(decryptToken('only-one-part')).rejects.toThrow();
30+
await expect(decryptToken('two.parts')).rejects.toThrow();
31+
await expect(decryptToken('a.b.c.d')).rejects.toThrow();
3232
});
3333

34-
it('rejects payload with invalid base64', () => {
34+
it('rejects payload with invalid base64', async () => {
3535
const payload = '!!!invalid-base64!!!.aaaa.aaaa';
36-
expect(() => decryptToken(payload)).toThrow();
36+
await expect(decryptToken(payload)).rejects.toThrow();
3737
});
3838

39-
it('rejects empty payload string', () => {
40-
expect(() => decryptToken('')).toThrow();
39+
it('rejects empty payload string', async () => {
40+
await expect(decryptToken('')).rejects.toThrow();
4141
});
4242

43-
it('rejects tampered ciphertext (modified encrypted part)', () => {
43+
it('rejects tampered ciphertext (modified encrypted part)', async () => {
4444
const plain = 'gho_secret_token';
45-
const encrypted = encryptToken(plain);
45+
const encrypted = await encryptToken(plain);
4646
const parts = encrypted.split('.');
4747
const tampered = [parts[0], parts[1], '////'].join('.');
48-
expect(() => decryptToken(tampered)).toThrow();
48+
await expect(decryptToken(tampered)).rejects.toThrow();
4949
});
5050

51-
it('rejects tampered auth tag (modified tag part)', () => {
51+
it('rejects tampered auth tag (modified tag part)', async () => {
5252
const plain = 'gho_secret_token';
53-
const encrypted = encryptToken(plain);
53+
const encrypted = await encryptToken(plain);
5454
const parts = encrypted.split('.');
5555
const tampered = [parts[0], '////', parts[2]].join('.');
56-
expect(() => decryptToken(tampered)).toThrow();
56+
await expect(decryptToken(tampered)).rejects.toThrow();
5757
});
5858

59-
it('rejects tampered IV (modified iv part)', () => {
59+
it('rejects tampered IV (modified iv part)', async () => {
6060
const plain = 'gho_secret_token';
61-
const encrypted = encryptToken(plain);
61+
const encrypted = await encryptToken(plain);
6262
const parts = encrypted.split('.');
6363
const tampered = ['////', parts[1], parts[2]].join('.');
64-
expect(() => decryptToken(tampered)).toThrow();
64+
await expect(decryptToken(tampered)).rejects.toThrow();
6565
});
6666
});
6767

6868
describe('crypto key errors', () => {
69-
it('throws when ENCRYPTION_KEY is missing', () => {
69+
it('throws when ENCRYPTION_KEY is missing', async () => {
7070
vi.stubEnv('ENCRYPTION_KEY', '');
71-
expect(() => encryptToken('test')).toThrow(/ENCRYPTION_KEY/i);
71+
await expect(encryptToken('test')).rejects.toThrow(/ENCRYPTION_KEY/i);
7272
});
7373

74-
it('throws when ENCRYPTION_KEY is too short', () => {
74+
it('throws when ENCRYPTION_KEY is too short', async () => {
7575
vi.stubEnv('ENCRYPTION_KEY', 'short');
76-
expect(() => encryptToken('test')).toThrow(/ENCRYPTION_KEY/i);
76+
await expect(encryptToken('test')).rejects.toThrow(/ENCRYPTION_KEY/i);
7777
});
7878

79-
it('throws on decrypt with wrong key', () => {
80-
const encrypted = encryptToken('secret');
79+
it('throws on decrypt with wrong key', async () => {
80+
const encrypted = await encryptToken('secret');
8181
vi.stubEnv('ENCRYPTION_KEY', 'a-different-key-that-is-32-char!!');
82-
expect(() => decryptToken(encrypted)).toThrow();
82+
await expect(decryptToken(encrypted)).rejects.toThrow();
8383
});
8484
});

lib/crypto.massive-scaling.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,50 +10,50 @@ afterEach(() => {
1010
});
1111

1212
describe('crypto massive scaling', () => {
13-
it('handles very long token strings without truncation', () => {
13+
it('handles very long token strings without truncation', async () => {
1414
const long = 'a'.repeat(10_000);
15-
const encrypted = encryptToken(long);
16-
expect(decryptToken(encrypted)).toBe(long);
15+
const encrypted = await encryptToken(long);
16+
expect(await decryptToken(encrypted)).toBe(long);
1717
});
1818

19-
it('handles unicode characters including emoji', () => {
19+
it('handles unicode characters including emoji', async () => {
2020
const unicode = '🚀🔥💯 commitpulse 测试 テスト тест';
21-
const encrypted = encryptToken(unicode);
22-
expect(decryptToken(encrypted)).toBe(unicode);
21+
const encrypted = await encryptToken(unicode);
22+
expect(await decryptToken(encrypted)).toBe(unicode);
2323
});
2424

25-
it('handles strings with special characters', () => {
25+
it('handles strings with special characters', async () => {
2626
const special = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~';
27-
const encrypted = encryptToken(special);
28-
expect(decryptToken(encrypted)).toBe(special);
27+
const encrypted = await encryptToken(special);
28+
expect(await decryptToken(encrypted)).toBe(special);
2929
});
3030

31-
it('handles JSON-serialized payload', () => {
31+
it('handles JSON-serialized payload', async () => {
3232
const payload = JSON.stringify({
3333
access_token: 'gho_xxx',
3434
scope: 'repo,user',
3535
token_type: 'bearer',
3636
});
37-
const encrypted = encryptToken(payload);
38-
expect(decryptToken(encrypted)).toBe(payload);
37+
const encrypted = await encryptToken(payload);
38+
expect(await decryptToken(encrypted)).toBe(payload);
3939
});
4040

41-
it('maintains round-trip integrity for repeated encryptions of same plaintext (different IV)', () => {
41+
it('maintains round-trip integrity for repeated encryptions of same plaintext (different IV)', async () => {
4242
const plain = 'gho_consistent_token_value';
4343
const results = new Set<string>();
4444
for (let i = 0; i < 10; i++) {
45-
results.add(encryptToken(plain));
45+
results.add(await encryptToken(plain));
4646
}
4747
expect(results.size).toBe(10);
48-
results.forEach((enc) => {
49-
expect(decryptToken(enc)).toBe(plain);
50-
});
48+
for (const enc of Array.from(results)) {
49+
expect(await decryptToken(enc)).toBe(plain);
50+
}
5151
});
5252

53-
it('handles maximum token length (512 bytes) typical for GitHub tokens', () => {
53+
it('handles maximum token length (512 bytes) typical for GitHub tokens', async () => {
5454
const typical = 'ghp_' + 'a'.repeat(508);
5555
expect(typical.length).toBe(512);
56-
const encrypted = encryptToken(typical);
57-
expect(decryptToken(encrypted)).toBe(typical);
56+
const encrypted = await encryptToken(typical);
57+
expect(await decryptToken(encrypted)).toBe(typical);
5858
});
5959
});

lib/crypto.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,52 @@ afterAll(() => {
1313
});
1414

1515
describe('encryptToken / decryptToken', () => {
16-
it('round-trips a plain text token', () => {
16+
it('round-trips a plain text token', async () => {
1717
const plain = 'ghp_test123token';
18-
const enc = encryptToken(plain);
18+
const enc = await encryptToken(plain);
1919
expect(enc).not.toBe(plain);
20-
expect(decryptToken(enc)).toBe(plain);
20+
expect(await decryptToken(enc)).toBe(plain);
2121
});
2222

23-
it('produces different ciphertexts for the same input (random salt/IV)', () => {
23+
it('produces different ciphertexts for the same input (random salt/IV)', async () => {
2424
const plain = 'same-value';
25-
const a = encryptToken(plain);
26-
const b = encryptToken(plain);
25+
const a = await encryptToken(plain);
26+
const b = await encryptToken(plain);
2727
expect(a).not.toBe(b);
2828
});
2929

30-
it('rejects a tampered ciphertext', () => {
31-
const enc = encryptToken('secret');
30+
it('rejects a tampered ciphertext', async () => {
31+
const enc = await encryptToken('secret');
3232
const parts = enc.split('.');
3333
parts[2] = Buffer.from('ffffffffffffffff').toString('base64');
34-
expect(() => decryptToken(parts.join('.'))).toThrow();
34+
await expect(decryptToken(parts.join('.'))).rejects.toThrow();
3535
});
3636

37-
it('throws on invalid payload format', () => {
38-
expect(() => decryptToken('not-a-valid-format')).toThrow();
37+
it('throws on invalid payload format', async () => {
38+
await expect(decryptToken('not-a-valid-format')).rejects.toThrow();
3939
});
4040

41-
it('handles empty string', () => {
42-
const enc = encryptToken('');
43-
expect(decryptToken(enc)).toBe('');
41+
it('handles empty string', async () => {
42+
const enc = await encryptToken('');
43+
expect(await decryptToken(enc)).toBe('');
4444
});
4545

46-
it('handles special characters', () => {
46+
it('handles special characters', async () => {
4747
const plain = 'abc123!@#$%^&*()_+=-[]{}|;:,.<>?/~`';
48-
expect(decryptToken(encryptToken(plain))).toBe(plain);
48+
expect(await decryptToken(await encryptToken(plain))).toBe(plain);
4949
});
5050

51-
it('throws when ENCRYPTION_KEY is missing', () => {
51+
it('throws when ENCRYPTION_KEY is missing', async () => {
5252
const saved = process.env.ENCRYPTION_KEY;
5353
delete process.env.ENCRYPTION_KEY;
54-
expect(() => encryptToken('x')).toThrow('ENCRYPTION_KEY');
54+
await expect(encryptToken('x')).rejects.toThrow('ENCRYPTION_KEY');
5555
process.env.ENCRYPTION_KEY = saved;
5656
});
5757

58-
it('throws when ENCRYPTION_KEY is too short', () => {
58+
it('throws when ENCRYPTION_KEY is too short', async () => {
5959
const saved = process.env.ENCRYPTION_KEY;
6060
process.env.ENCRYPTION_KEY = 'short';
61-
expect(() => encryptToken('x')).toThrow('ENCRYPTION_KEY');
61+
await expect(encryptToken('x')).rejects.toThrow('ENCRYPTION_KEY');
6262
process.env.ENCRYPTION_KEY = saved;
6363
});
6464
});

0 commit comments

Comments
 (0)