Test for SSRF vulnerabilities that reach internal services, verify insecure design patterns like email enumeration and unbounded queries, and implement defensive test patterns.
Server-Side Request Forgery (SSRF) allows attackers to make the server issue requests to internal services that are not accessible from the internet — including the AWS instance metadata service at 169.254.169.254 that exposes IAM credentials.
Insecure Design covers architectural security flaws: missing rate limiting, verbose error messages, email enumeration, and lack of defense-in-depth. Both require testing at the architecture level.
SSRF allows attackers to access internal services via the server.
Classic SSRF targets:
1http://169.254.169.254/latest/meta-data/iam/ # AWS IMDSv1 credentials
2http://metadata.google.internal/ # GCP metadata
3http://localhost:6379/ # Internal Redis
4http://127.0.0.1:5432/ # Internal PostgreSQL
5http://10.0.0.1/admin # Private IP range
6file:///etc/passwd # Local file inclusion
7gopher://internal:11211/ # Memcache1// VULNERABLE: user controls the URL
2@Post('webhooks/test')
3async testWebhook(@Body() body: { url: string }) {
4 const response = await fetch(body.url); // SSRF!
5 return response.json();
6}
7
8// SAFE: allowlist of permitted hostnames
9const ALLOWED_HOSTS = ['api.stripe.com', 'hooks.slack.com'];
10
11@Post('webhooks/test')
12async testWebhook(@Body('url') url: string) {
13 const parsed = new URL(url);
14 if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
15 throw new BadRequestException('URL not in allowed list');
16 }
17 if (parsed.protocol !== 'https:') {
18 throw new BadRequestException('Only HTTPS URLs allowed');
19 }
20 return fetch(url).then(r => r.json());
21}1describe('SSRF Prevention', () => {
2 const SSRF_PAYLOADS = [
3 { url: 'http://169.254.169.254/latest/meta-data/', desc: 'AWS metadata' },
4 { url: 'http://localhost:6379/', desc: 'local Redis' },
5 { url: 'http://10.0.0.1/', desc: 'private IP 10.x' },
6 { url: 'http://192.168.1.1/', desc: 'private IP 192.168.x' },
7 { url: 'http://[::1]:3000/', desc: 'IPv6 loopback' },
8 { url: 'file:///etc/passwd', desc: 'file URI' },
9 { url: 'gopher://internal/', desc: 'gopher protocol' },
10 { url: 'http://0.0.0.0/', desc: 'zero address' },
11 ];
12
13 SSRF_PAYLOADS.forEach(({ url, desc }) => {
14 it('blocks ' + desc + ': ' + url, async () => {
15 const res = await request(app.getHttpServer())
16 .post('/integrations/webhook-test')
17 .set('Authorization', 'Bearer ' + userToken)
18 .send({ callbackUrl: url });
19
20 expect(res.status).toBe(400);
21 const body = JSON.stringify(res.body);
22 // Must not reveal internal service responses
23 expect(body).not.toMatch(/AccessKeyId|SecretAccessKey|redis_version/);
24 });
25 });
26
27 it('allows approved external hosts', async () => {
28 const res = await request(app.getHttpServer())
29 .post('/integrations/webhook-test')
30 .set('Authorization', 'Bearer ' + userToken)
31 .send({ callbackUrl: 'https://api.stripe.com/v1/events' });
32 // Should not return 400 (not blocked by SSRF protection)
33 expect(res.status).not.toBe(400);
34 });
35});1describe('Insecure Design Prevention', () => {
2 it('pagination limit is capped to prevent unbounded queries', async () => {
3 const res = await request(app.getHttpServer())
4 .get('/api/courses?limit=99999')
5 .set('Authorization', 'Bearer ' + userToken);
6 if (res.status === 200) {
7 expect(res.body.data?.length || 0).toBeLessThanOrEqual(100);
8 } else {
9 expect(res.status).toBe(400);
10 }
11 });
12
13 it('search query has maximum length', async () => {
14 const res = await request(app.getHttpServer())
15 .get('/api/search?q=' + 'A'.repeat(501));
16 expect([400, 414]).toContain(res.status);
17 });
18
19 it('email enumeration is prevented on forgot password', async () => {
20 const [existingRes, nonExistingRes] = await Promise.all([
21 request(app.getHttpServer())
22 .post('/auth/forgot-password')
23 .send({ email: '[email protected]' }),
24 request(app.getHttpServer())
25 .post('/auth/forgot-password')
26 .send({ email: '[email protected]' }),
27 ]);
28 expect(existingRes.status).toBe(nonExistingRes.status);
29 expect(existingRes.body.message).toBe(nonExistingRes.body.message);
30 });
31
32 it('bulk operations have maximum batch size', async () => {
33 const tooMany = Array.from({ length: 1001 }, (_, i) => 'id-' + i);
34 const res = await request(app.getHttpServer())
35 .post('/courses/bulk-enroll')
36 .set('Authorization', 'Bearer ' + userToken)
37 .send({ courseIds: tooMany });
38 expect(res.status).toBe(400);
39 });
40});1describe('Information Disclosure Prevention', () => {
2 it('database errors are not exposed to clients', async () => {
3 const res = await request(app.getHttpServer())
4 .get('/api/courses/not-a-valid-cuid')
5 .set('Authorization', 'Bearer ' + userToken);
6 const body = JSON.stringify(res.body);
7 expect(body).not.toMatch(/prisma|postgres|sql syntax/i);
8 expect(body).not.toMatch(/at Object\.|node_modules/);
9 });
10
11 it('auth errors do not reveal account existence', async () => {
12 const [existingRes, ghostRes] = await Promise.all([
13 request(app.getHttpServer())
14 .post('/auth/login')
15 .send({ email: '[email protected]', password: 'WrongPass!' }),
16 request(app.getHttpServer())
17 .post('/auth/login')
18 .send({ email: '[email protected]', password: 'WrongPass!' }),
19 ]);
20 expect(existingRes.status).toBe(ghostRes.status);
21 expect(existingRes.body.message).toBe(ghostRes.body.message);
22 });
23
24 it('user profile omits sensitive fields', async () => {
25 const res = await request(app.getHttpServer())
26 .get('/user/profile')
27 .set('Authorization', 'Bearer ' + userToken);
28 expect(res.body.passwordHash).toBeUndefined();
29 expect(res.body.twoFactorSecret).toBeUndefined();
30 expect(res.body.emailVerificationToken).toBeUndefined();
31 });
32
33 it('response headers do not reveal stack', async () => {
34 const res = await request(app.getHttpServer()).get('/api/health');
35 expect(res.headers['x-powered-by']).toBeUndefined();
36 expect(res.headers['server']).toBeUndefined();
37 });
38});1describe('Defense-in-Depth', () => {
2 it('session token is invalidated on logout', async () => {
3 const loginRes = await request(app.getHttpServer())
4 .post('/auth/login')
5 .send({ email: '[email protected]', password: 'Pass123!' });
6 const token = loginRes.body.accessToken;
7
8 await request(app.getHttpServer())
9 .post('/auth/logout')
10 .set('Authorization', 'Bearer ' + token);
11
12 const profileRes = await request(app.getHttpServer())
13 .get('/user/profile')
14 .set('Authorization', 'Bearer ' + token);
15 expect(profileRes.status).toBe(401);
16 });
17
18 it('concurrent enrollment is idempotent (race condition prevention)', async () => {
19 const [res1, res2] = await Promise.all([
20 request(app.getHttpServer())
21 .post('/progress/enroll')
22 .set('Authorization', 'Bearer ' + userToken)
23 .send({ courseId: 'paid-course-123' }),
24 request(app.getHttpServer())
25 .post('/progress/enroll')
26 .set('Authorization', 'Bearer ' + userToken)
27 .send({ courseId: 'paid-course-123' }),
28 ]);
29 // One should succeed, one should conflict or be idempotent
30 const statuses = [res1.status, res2.status];
31 // Should not both be 201 (duplicate enrollment)
32 expect(statuses.filter(s => s === 201).length).toBeLessThanOrEqual(1);
33 });
34});Comprehensive test suite covering SSRF prevention, information disclosure, input validation, and defense-in-depth controls.
1// apps/api/src/security/__tests__/design-security.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { AppModule } from '../../app.module';
6
7describe('SSRF and Insecure Design Tests (OWASP A04 + A10)', () => {
8 let app: INestApplication;
9 let userToken: string;
10
11 beforeAll(async () => {
12 const module: TestingModule = await Test.createTestingModule({
13 imports: [AppModule],
14 }).compile();
15 app = module.createNestApplication();
16 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
17 await app.init();
18
19 const loginRes = await request(app.getHttpServer())
20 .post('/auth/login')
21 .send({ email: '[email protected]', password: 'TestPass123!' });
22 userToken = loginRes.body.accessToken;
23 });
24
25 afterAll(() => app.close());
26
27 describe('SSRF prevention', () => {
28 const ssrfPayloads = [
29 { url: 'http://169.254.169.254/latest/meta-data/', desc: 'AWS metadata' },
30 { url: 'http://localhost:6379/', desc: 'Redis' },
31 { url: 'http://10.0.0.1/', desc: 'private 10.x' },
32 { url: 'http://192.168.1.1/', desc: 'private 192.168.x' },
33 { url: 'file:///etc/passwd', desc: 'file URI' },
34 ];
35
36 ssrfPayloads.forEach(({ url, desc }) => {
37 it('blocks ' + desc, async () => {
38 const res = await request(app.getHttpServer())
39 .post('/integrations/webhook-test')
40 .set('Authorization', 'Bearer ' + userToken)
41 .send({ callbackUrl: url });
42 expect(res.status).toBe(400);
43 const body = JSON.stringify(res.body);
44 expect(body).not.toMatch(/AccessKeyId|SecretAccessKey/);
45 });
46 });
47 });
48
49 describe('Input validation and bounded queries', () => {
50 it('caps pagination at maximum', async () => {
51 const res = await request(app.getHttpServer())
52 .get('/api/courses?limit=1000')
53 .set('Authorization', 'Bearer ' + userToken);
54 if (res.status === 200) {
55 expect(res.body.data?.length || 0).toBeLessThanOrEqual(100);
56 } else {
57 expect(res.status).toBe(400);
58 }
59 });
60
61 it('prevents email enumeration on password reset', async () => {
62 const [existingRes, nonExistingRes] = await Promise.all([
63 request(app.getHttpServer()).post('/auth/forgot-password').send({ email: '[email protected]' }),
64 request(app.getHttpServer()).post('/auth/forgot-password').send({ email: '[email protected]' }),
65 ]);
66 expect(existingRes.body.message).toBe(nonExistingRes.body.message);
67 });
68 });
69
70 describe('Information disclosure prevention', () => {
71 it('database errors not exposed to clients', async () => {
72 const res = await request(app.getHttpServer())
73 .get('/api/courses/not-a-valid-cuid')
74 .set('Authorization', 'Bearer ' + userToken);
75 const body = JSON.stringify(res.body);
76 expect(body).not.toMatch(/prisma|postgres|sql/i);
77 expect(body).not.toMatch(/at Object\.|node_modules/);
78 });
79
80 it('login errors identical for existing/non-existing accounts', async () => {
81 const [existingRes, ghostRes] = await Promise.all([
82 request(app.getHttpServer()).post('/auth/login').send({ email: '[email protected]', password: 'WrongPass!' }),
83 request(app.getHttpServer()).post('/auth/login').send({ email: '[email protected]', password: 'WrongPass!' }),
84 ]);
85 expect(existingRes.status).toBe(ghostRes.status);
86 expect(existingRes.body.message).toBe(ghostRes.body.message);
87 });
88
89 it('profile omits sensitive fields', async () => {
90 const res = await request(app.getHttpServer())
91 .get('/user/profile')
92 .set('Authorization', 'Bearer ' + userToken);
93 expect(res.body.passwordHash).toBeUndefined();
94 expect(res.body.twoFactorSecret).toBeUndefined();
95 });
96 });
97});This test suite covers OWASP A04 (Insecure Design) and A10 (SSRF) with concrete test cases. SSRF tests 5 classic attack targets (AWS metadata, Redis, private IPs, file:// protocol). Input validation verifies pagination caps and email enumeration prevention. Information disclosure tests verify database errors and login errors don't reveal internal details. All tests use real HTTP requests against the full NestJS application with ValidationPipe configured as in production.
The next lesson covers penetration testing basics — setting up Burp Suite Community Edition, intercepting API requests, following the OWASP Web Security Testing Guide checklist, and documenting findings.
Your progress is saved in this browser, on this device only.