forked from SmartDropLabs/smartdrop-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhooks.ssrf.test.js
More file actions
159 lines (126 loc) · 5.57 KB
/
Copy pathwebhooks.ssrf.test.js
File metadata and controls
159 lines (126 loc) · 5.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
'use strict';
// Keep the test rate-limit budget small so we can prove that testing many
// *different* webhooks from one IP does NOT bypass the per-IP limit. Must be
// set before config is required below.
process.env.WEBHOOK_TEST_RATELIMIT_MAX = '5';
process.env.WEBHOOK_TEST_RATELIMIT_WINDOW = '60';
const express = require('express');
const request = require('supertest');
const { createCacheMock } = require('./helpers/cacheMock');
const mockHelper = createCacheMock();
const { reset } = mockHelper;
jest.mock('../src/services/cache', () => mockHelper.cacheMock);
const mockAxiosPost = jest.fn();
jest.mock('axios', () => ({ post: (...args) => mockAxiosPost(...args) }));
const webhooksRouter = require('../src/routes/webhooks');
const webhookRepo = require('../src/repositories/webhookRepository');
const logger = require('../src/logger');
const { errorHandler, notFoundHandler } = require('../src/middleware/errorHandler');
// A real, public IP literal (example.com's historical address) — used so the
// SSRF guard's resolution path is exercised without any live DNS lookup.
const PUBLIC_TARGET = 'http://93.184.216.34/';
function buildApp() {
const app = express();
app.use(express.json());
app.use('/api/v1', webhooksRouter);
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
async function seedWebhook(url) {
return webhookRepo.create({
url,
events: ['*'],
secret: 'whsec_aaaaaaaaaaaaaaaa',
});
}
beforeEach(() => {
reset();
mockAxiosPost.mockReset();
});
describe('webhook SSRF guard (#96)', () => {
const app = buildApp();
test.each([
['RFC1918', 'http://10.0.0.5/'],
['loopback', 'http://127.0.0.1/'],
['link-local cloud metadata', 'http://169.254.169.254/latest/meta-data/'],
['IPv6 loopback', 'http://[::1]/'],
])('refuses testing a webhook targeting a private/internal address (%s)', async (label, url) => {
const webhook = await seedWebhook(url);
const res = await request(app).post(`/api/v1/webhooks/${webhook.id}/test`);
expect(res.status).toBe(422);
expect(res.body.error.code).toBe('WEBHOOK_TARGET_BLOCKED');
expect(mockAxiosPost).not.toHaveBeenCalled();
});
test('blocks a private target even when seeded directly, bypassing create-time validation (defense in depth)', async () => {
// The route-level schema would reject a private URL at POST /webhooks, so
// seed straight into the repository to simulate a bypass/refactor gap and
// prove the delivery-time check is what actually stops it.
const webhook = await seedWebhook('http://192.168.1.1/');
const res = await request(app).post(`/api/v1/webhooks/${webhook.id}/test`);
expect(res.status).toBe(422);
expect(res.body.error.code).toBe('WEBHOOK_TARGET_BLOCKED');
});
test('allows testing a public target and reports success', async () => {
const webhook = await seedWebhook(PUBLIC_TARGET);
mockAxiosPost.mockResolvedValueOnce({ status: 200 });
const res = await request(app).post(`/api/v1/webhooks/${webhook.id}/test`);
expect(res.status).toBe(202);
expect(res.body.status).toBe('success');
});
});
describe('webhook test endpoint error detail reduction (#96)', () => {
const app = buildApp();
let warnSpy;
beforeEach(() => {
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
test('does not leak raw network error codes; returns a generic category but logs the raw detail', async () => {
const webhook = await seedWebhook(PUBLIC_TARGET);
mockAxiosPost.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const res = await request(app).post(`/api/v1/webhooks/${webhook.id}/test`);
expect(res.status).toBe(202);
expect(res.body.last_error).toBe('unreachable');
expect(String(res.body.last_error)).not.toContain('ECONNREFUSED');
// Raw low-level detail is preserved server-side for operators.
const loggedRaw = warnSpy.mock.calls.some(([msg, meta]) =>
String(msg).toLowerCase().includes('failed') &&
meta && String(meta.error).includes('ECONNREFUSED'));
expect(loggedRaw).toBe(true);
});
test('does not leak raw HTTP error strings; returns a generic category', async () => {
const webhook = await seedWebhook(PUBLIC_TARGET);
mockAxiosPost.mockResolvedValueOnce({ status: 500 });
const res = await request(app).post(`/api/v1/webhooks/${webhook.id}/test`);
expect(res.status).toBe(202);
expect(res.body.last_error).toBe('error_response');
expect(String(res.body.last_error)).not.toMatch(/^HTTP /);
});
});
describe('webhook test rate-limit aggregates per IP (#96)', () => {
const app = buildApp();
test('testing N different webhooks from one IP shares a single budget', async () => {
const webhooks = [];
for (let i = 0; i < 10; i += 1) {
// Distinct public targets so the SSRF guard passes for each.
webhooks.push(await seedWebhook(`http://93.184.216.${34 + i % 3}/`));
}
mockAxiosPost.mockResolvedValue({ status: 200 });
const results = [];
for (const wh of webhooks) {
results.push(await request(app).post(`/api/v1/webhooks/${wh.id}/test`));
}
const succeeded = results.filter((r) => r.status === 202).length;
const limited = results.filter((r) => r.status === 429).length;
// Budget is 5 per IP per window — testing 10 *different* webhooks must not
// each get their own allowance; only 5 succeed, the rest are limited.
expect(succeeded).toBe(5);
expect(limited).toBe(5);
results.filter((r) => r.status === 429).forEach((r) => {
expect(r.body.error.code).toBe('RATE_LIMITED');
});
});
});