Skip to content

Commit b209b2d

Browse files
committed
Add security hardening and testing documentation
Implement multiple security improvements and expand testing documentation.
1 parent 6e42bca commit b209b2d

10 files changed

Lines changed: 254 additions & 42 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ Thumbs.db
2626
# Logs
2727
*.log
2828
npm-debug.log*
29+
30+
# Claude
31+
CLAUDE.md

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,34 @@ Authorization: Bearer <your-secret>
252252

253253
The Worker validates this header on `/webhook` requests if the `WEBHOOK_SECRET` environment variable is set. If it's not set, the webhook accepts all valid payloads (the default behavior).
254254

255+
### Optional: Rate Limiting the Webhook Endpoint
256+
257+
The `/webhook` endpoint is open to the internet so devices can POST enrollment events. To prevent abuse (flooding with fake events, exhausting KV storage), you can add a Cloudflare WAF rate limiting rule. This is configured entirely in the Cloudflare dashboard — no code changes required.
258+
259+
#### Setup
260+
261+
1. In the [Cloudflare dashboard](https://dash.cloudflare.com), select your account and domain (or Workers route)
262+
2. Go to **Security → WAF → Rate limiting rules**
263+
3. Click **Create rule** and configure:
264+
- **Rule name:** `Rate limit webhook`
265+
- **If incoming requests match:** Field `URI Path` — Operator `equals` — Value `/webhook`
266+
- **Rate:** `30 requests` per `1 minute` (adjust based on your fleet size)
267+
- **With the same:** `IP Address`
268+
- **Then:** `Block` for `1 minute`
269+
4. Deploy the rule
270+
271+
#### Choosing the Right Rate
272+
273+
The rate depends on how many devices enroll simultaneously from the same IP. Each device sends exactly two webhook requests per enrollment (one `started`, one `finished`), so:
274+
275+
| Fleet scenario | Concurrent enrollments from one IP | Suggested rate |
276+
|---|---|---|
277+
| Small office (1–10 devices) | 1–10 | 30 req/min |
278+
| Medium site (10–50 devices) | 10–50 | 120 req/min |
279+
| Large deployment (50+ from one NAT IP) | 50+ | 300 req/min |
280+
281+
If your devices enroll behind a shared NAT or VPN gateway, choose a higher limit to avoid blocking legitimate traffic. You can always start with a permissive limit and tighten it after observing real traffic patterns in **Security → Analytics**.
282+
255283
### Access Configuration Summary
256284

257285
| Route | Authentication | Who |
@@ -277,6 +305,43 @@ For local Worker development, create a `.dev.vars` file (see `.dev.vars.example`
277305

278306
> **Note:** Cloudflare Access is not active during local development. The dashboard is unprotected when running locally - this is expected and convenient for development.
279307
308+
## Testing the Dashboard
309+
310+
After deploying, you can populate the dashboard with dummy data to verify everything is working.
311+
312+
### Send Dummy Events
313+
314+
The included test script generates 140 realistic webhook events (70 started + 70 finished) across 10 simulated devices, spread over the past 3 days. This gives the dashboard enough data to display KPIs, charts, and event details.
315+
316+
```bash
317+
# Replace with your actual Worker URL
318+
WORKER_URL=https://setup-manager-hud.<your-subdomain>.workers.dev \
319+
node scripts/send-dummy-events.js
320+
```
321+
322+
If you have a `WEBHOOK_SECRET` configured on your Worker, pass it along:
323+
324+
```bash
325+
WORKER_URL=https://setup-manager-hud.<your-subdomain>.workers.dev \
326+
WEBHOOK_SECRET=your-secret-here \
327+
node scripts/send-dummy-events.js
328+
```
329+
330+
Once the script finishes, open the dashboard in your browser. You should see events appearing with device details, enrollment actions, and charts populated with data.
331+
332+
### Cleaning Up Test Data from KV
333+
334+
After testing, you'll likely want to remove the dummy events. Cloudflare KV entries have a 90-day TTL so they will expire on their own, but you can remove them immediately through the Cloudflare dashboard:
335+
336+
1. Log in to the [Cloudflare dashboard](https://dash.cloudflare.com)
337+
2. Go to **Workers & Pages → KV** in the left sidebar
338+
3. Click on your **WEBHOOKS** namespace
339+
4. You'll see a list of stored keys — dummy events use serial numbers starting with `DUMMY` (e.g. `com.jamf.setupmanager.started:DUMMY000001:...`)
340+
5. To delete individual entries: click the **three-dot menu** next to an entry and select **Delete**
341+
6. To bulk delete all test data: select entries using the checkboxes, then click **Delete selected**
342+
343+
> **Tip:** You can use the search/filter field at the top of the KV viewer to filter keys containing `DUMMY` to quickly find and select all test entries.
344+
280345
## Architecture
281346

282347
```

public/jamf-icon-dark.svg

Lines changed: 6 additions & 0 deletions
Loading

public/jamf-icon-white.svg

Lines changed: 6 additions & 0 deletions
Loading

scripts/send-dummy-events.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1-
const WORKER_URL = process.env.WORKER_URL || 'https://setup-manager-hud.motionbug-site.workers.dev';
1+
/**
2+
* send-dummy-events.js
3+
*
4+
* Generates dummy Setup Manager webhook events and POSTs them to your
5+
* Setup Manager HUD instance. Useful for verifying the dashboard works
6+
* after a fresh deploy or for demo purposes.
7+
*
8+
* Usage:
9+
* WORKER_URL=https://your-worker.your-subdomain.workers.dev node scripts/send-dummy-events.js
10+
*
11+
* If WEBHOOK_SECRET is set on your Worker, pass it as an env variable:
12+
* WORKER_URL=https://your-worker.your-subdomain.workers.dev \
13+
* WEBHOOK_SECRET=your-secret-here \
14+
* node scripts/send-dummy-events.js
15+
*
16+
* What it does:
17+
* - Creates 10 dummy devices with random Mac models and macOS versions
18+
* - Sends 70 started events and 70 matching finished events (7 per device)
19+
* - Events are spread over the last 3 days so charts have data to display
20+
* - ~5% of enrollment actions are randomly marked as "failed"
21+
*/
22+
23+
const WORKER_URL = process.env.WORKER_URL;
24+
if (!WORKER_URL) {
25+
console.error('Error: WORKER_URL environment variable is required.\n');
26+
console.error('Usage:');
27+
console.error(' WORKER_URL=https://your-worker.your-subdomain.workers.dev node scripts/send-dummy-events.js\n');
28+
process.exit(1);
29+
}
230
const WEBHOOK_URL = `${WORKER_URL}/webhook`;
31+
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
332

433
const MODELS = [
534
{ name: 'MacBook Air', identifier: 'Mac14,2' },
@@ -85,9 +114,14 @@ function buildFinishedPayload(device, startedTime, durationSeconds) {
85114
}
86115

87116
async function sendPayload(payload) {
117+
const headers = { 'Content-Type': 'application/json' };
118+
if (WEBHOOK_SECRET) {
119+
headers['Authorization'] = `Bearer ${WEBHOOK_SECRET}`;
120+
}
121+
88122
const response = await fetch(WEBHOOK_URL, {
89123
method: 'POST',
90-
headers: { 'Content-Type': 'application/json' },
124+
headers,
91125
body: JSON.stringify(payload)
92126
});
93127

src/DashboardRoom.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,19 @@ export class DashboardRoom implements DurableObject {
7272
return new Response("Not Found", { status: 404 });
7373
}
7474

75+
/** Maximum accepted WebSocket message size (bytes) */
76+
private static readonly MAX_WS_MESSAGE_SIZE = 4096;
77+
7578
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
7679
try {
80+
const messageLength =
81+
typeof message === "string" ? message.length : message.byteLength;
82+
83+
if (messageLength > DashboardRoom.MAX_WS_MESSAGE_SIZE) {
84+
ws.send(JSON.stringify({ type: "error", message: "Message too large" }));
85+
return;
86+
}
87+
7788
if (typeof message === "string") {
7889
const data = JSON.parse(message);
7990

@@ -82,7 +93,11 @@ export class DashboardRoom implements DurableObject {
8293
}
8394

8495
if (data.type === "request-history") {
85-
const limit = typeof data.limit === "number" ? data.limit : 200;
96+
const MAX_HISTORY = 200;
97+
const limit =
98+
typeof data.limit === "number"
99+
? Math.min(Math.max(data.limit, 1), MAX_HISTORY)
100+
: MAX_HISTORY;
86101
await this.sendHistory(ws, limit);
87102
}
88103
}

src/components/dashboard/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,12 @@ function PoweredByJamf() {
216216
<span className="inline-flex items-center gap-2">
217217
Powered by Jamf
218218
<img
219-
src="https://setup-manager-hud.motionbug-site.workers.dev/jamf-icon-white.svg"
219+
src="/jamf-icon-white.svg"
220220
alt="Jamf"
221221
className="hidden h-[1.05em] w-auto dark:block"
222222
/>
223223
<img
224-
src="https://setup-manager-hud.motionbug-site.workers.dev/jamf-icon-dark.svg"
224+
src="/jamf-icon-dark.svg"
225225
alt="Jamf"
226226
className="block h-[1.05em] w-auto dark:hidden"
227227
/>

src/components/dashboard/Filters.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ export function Filters({ filters, onFiltersChange, events }: FiltersProps) {
6565
URL.revokeObjectURL(url);
6666
};
6767

68+
/**
69+
* Sanitize a string for safe CSV output.
70+
* Prefixes cells that start with formula-triggering characters with a
71+
* single quote so spreadsheet apps (Excel, Sheets) treat them as text
72+
* rather than executable formulas.
73+
*/
74+
const sanitizeCsvValue = (str: string): string => {
75+
const FORMULA_CHARS = ["=", "+", "-", "@", "\t", "\r", "\n"];
76+
if (FORMULA_CHARS.some((c) => str.startsWith(c))) {
77+
str = "'" + str;
78+
}
79+
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
80+
return `"${str.replace(/"/g, '""')}"`;
81+
}
82+
return str;
83+
};
84+
6885
const toCsv = (rows: WebhookPayload[]) => {
6986
const headers = [
7087
"event", "timestamp", "started", "finished", "duration",
@@ -75,10 +92,7 @@ export function Filters({ filters, onFiltersChange, events }: FiltersProps) {
7592
const values = headers.map((h) => {
7693
const v = (payload as unknown as Record<string, unknown>)[h];
7794
if (v === undefined || v === null) return "";
78-
const str = String(v);
79-
return str.includes(",") || str.includes('"')
80-
? `"${str.replace(/"/g, '""')}"`
81-
: str;
95+
return sanitizeCsvValue(String(v));
8296
});
8397
lines.push(values.join(","));
8498
});

0 commit comments

Comments
 (0)