From 26571a9e65101a2c000ea428dd870883b887234c Mon Sep 17 00:00:00 2001 From: Purushotham Date: Thu, 23 Jul 2026 22:57:51 +0200 Subject: [PATCH 1/3] Add security challenge deliverables: CORS/XFF analysis, ZAP evidence, Slack response bot Part 1 (CORS): reproduced the credentialed cross-origin data theft and documented root cause, delivery vectors, and prevention (evidence/cors-attack-repro.md). Part 2 (XFF spoofing): proved req.ip is attacker-controllable due to trust proxy: true; documented GCP/LB hop-count fix and edge rate-limiting recommendation (evidence/xff-spoof-repro.md). Parts 3-4: ELK-on-Cloud-Run log pipeline design and hybrid detection approach for the Part 1 attack (REPORT.md). Part 5 (Slack): standalone Bolt Socket Mode bot (slackbot/) with node:sqlite persistence; real detection hook in app.js posts an interactive alert on a credentialed cross-origin request; classification updates the message in place and persists state. Verified end-to-end in the workspace. Evidence: OWASP ZAP baseline scan report + notes (evidence/). --- CLAUDE.md | 52 + REPORT.md | 172 ++ app.js | 32 +- evidence/cors-attack-repro.md | 71 + evidence/part5-slackbot-repro.md | 71 + evidence/xff-spoof-repro.md | 49 + evidence/zap-report.html | 3941 ++++++++++++++++++++++++++++++ evidence/zap-report.json | 1029 ++++++++ evidence/zap-scan-notes.md | 58 + evidence/zap.yaml | 40 + slackbot/.env.example | 5 + slackbot/.gitignore | 3 + slackbot/README.md | 80 + slackbot/db.js | 63 + slackbot/index.js | 184 ++ slackbot/package.json | 18 + slackbot/pnpm-lock.yaml | 2189 +++++++++++++++++ 17 files changed, 8055 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 REPORT.md create mode 100644 evidence/cors-attack-repro.md create mode 100644 evidence/part5-slackbot-repro.md create mode 100644 evidence/xff-spoof-repro.md create mode 100644 evidence/zap-report.html create mode 100644 evidence/zap-report.json create mode 100644 evidence/zap-scan-notes.md create mode 100644 evidence/zap.yaml create mode 100644 slackbot/.env.example create mode 100644 slackbot/.gitignore create mode 100644 slackbot/README.md create mode 100644 slackbot/db.js create mode 100644 slackbot/index.js create mode 100644 slackbot/package.json create mode 100644 slackbot/pnpm-lock.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52f31fc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repo is + +This is `cors-demo`, a security *training* application (branded "Natural Cycles Security Training") that intentionally demonstrates client-side/CORS data-theft vulnerabilities so engineers can see the attack in action. Almost every file here is deliberately insecure — that is the point of the app. Do not "fix" the vulnerabilities in `app.js` or the views unless a task explicitly asks for that; the misconfigured CORS/cookie logic is the product, not a bug. + +## Running the app + +- Install: `pnpm install` +- Start: `pnpm start` (runs `node app.js`) +- Serves HTTPS on port 3000 using the checked-in self-signed cert (`www.nc.com.key` / `www.nc.com.pem`) +- Browse to `https://localhost:3000` (or `https://www.nc.com:3000`) and accept the self-signed cert warning +- The attack scenarios require simulating two origins on one machine. Add to `/etc/hosts`: + ``` + 127.0.0.1 www.nc.com www.evil.com + ``` +- Third-party cookies must be allowed in the browser for the CORS scenario to work +- There are no build steps, no test suite, and no linter configured in this repo — `pnpm start` is the only script + +## Architecture + +Single-file Express ("Express 5") server (`app.js`) with EJS views, no database/session store despite `mongodb`/`monk` being listed as dependencies (unused in current `app.js`). Everything hinges on a small set of middleware behaviors near the top of `app.js`: + +1. **`POST -> GET` override hack**: any POST to a path matching `*appInit` is rewritten to `GET` before continuing, so a real backend JSON endpoint can be simulated by Express's static file server. +2. **Fake-JSON-via-static-file trick**: paths starting with `/json-` get `Content-Type: application/json` forced, then fall through to `express.static('public')`, which serves flat files under `public/json-default/`, `public/json-cors-origin/`, `public/json-cors-wildcard/` (e.g. `public/json-cors-origin/appInit`). These static files are the "backend response" payloads (fake user data, PII, etc.) used to prove data exfiltration worked. The `objects/` directory holds the canonical copies of these JSON fixtures (`appInit`, `postInit`, `webSignupInit`) that the `public/json-*` files are copied from. +3. **CORS header branches by path prefix**, controlled by the module-level `allowOrigin` variable (`'*' | 'origin' | 'null'`) near the top of `app.js`: + - `/json-default/*` — no CORS headers set at all (baseline/safe comparison case) + - `/json-cors-wildcard/*` — sets `Access-Control-Allow-Origin` to `*` or to the reflected `Origin` depending on `allowOrigin`, plus `Allow-Credentials: true` (a known-bad combination the demo exploits) + - `/json-cors-origin/*` — always reflects the request's `Origin` header (or defaults to `https://:3000`) back as `Access-Control-Allow-Origin`, with credentials allowed — this is the "vulnerable" explicit-origin scenario + - There's a dead/disabled branch (`false && ...`) left in place showing an earlier variant of the wildcard logic; it's unreachable and not worth "cleaning up" unless asked. +4. **Cookie-gated JSON routes**: `blockIfNoCookie` rejects any `/json-*` request with HTTP 400 unless a `superSecretSession` cookie is present, simulating an authenticated backend call that will be stolen via CORS. +5. **Login**: `GET /login` sets `superSecretSession` as a non-httpOnly cookie (`res.cookie(...)`), so it can also be read/exfiltrated from JS (used by the local-storage scenario). +6. **Local-storage theft scenario**: `PUT /local-storage/yummy` (with a matching `OPTIONS` preflight handler) reflects `Origin` back and accepts arbitrary JSON bodies, simulating an attacker endpoint receiving stolen `localStorage` contents. + +### View/route structure + +- `views/index.ejs` — landing page linking to the active demo scenarios. The Local Storage scenario section is currently commented out (see `54b5224 feat: hide local storage scenario`); the routes and views for it (`views/local-storage/*`, `public/steal.js`) still exist and work, just aren't linked from the homepage. +- `views/cors/allow-origin/{index,webapp,attacker}.ejs` — the CORS attack walkthrough: + - `webapp.ejs` — a fake "target" site (`www.nc.com`) where a victim logs in (sets the `superSecretSession` cookie via JS, `SameSite=None; Secure`) and stores "secret" data + - `attacker.ejs` — the malicious third-party page (served from `www.evil.com`) that loads `public/front.js`, which fires a cross-origin, credentialed AJAX request at `/json-cors-origin/appInit` (or `/json-cors-wildcard/appInit` depending on `mode` attribute on the script tag) and displays whatever "stolen" data comes back +- `views/local-storage/{index,webapp,vulnerable}.ejs` + `public/steal.js` — parallel scenario for exfiltrating `localStorage` (rather than a cookie-authenticated API) to an attacker-controlled `PUT` endpoint +- `public/cookie-steal.js` — a minimal image-beacon cookie exfiltration payload, referenced conceptually rather than wired into a specific route + +### Key files to read together when modifying a scenario + +Because the attack flow spans server route, static fixture data, and two+ EJS views, always check all of these before changing one: +- `app.js` (routing + CORS header logic for the scenario's path prefix) +- `objects/` and the matching `public/json-*/` copy (the payload "stolen") +- the relevant `views//*.ejs` pair (victim/webapp page + attacker page) +- the relevant `public/*.js` (e.g. `front.js`, `steal.js`) that performs the cross-origin call diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..94b5e43 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,172 @@ +# Security & Infrastructure Engineer Challenge — Report + +Author: Purushotham Muktha +Target: `cors-demo` vulnerable webapp (this repo), run locally per `README.md` +Evidence referenced below lives in `evidence/`. + +--- + +## Part 1: CORS Vulnerability + +### Setup + +Ran the app per `README.md` (`pnpm install && pnpm start`), added the required `/etc/hosts` entries, and walked the **"Theft successful - Set allow to Origin"** scenario end to end: visited the attacker blog, logged into the fake NC webapp, returned to the attacker blog, and observed the threatening "stolen data" message render successfully. + +Full reproduction (browser steps + an automated curl equivalent + captured request/response headers) is in **`evidence/cors-attack-repro.md`**. + +### 1. Why does the attack work? + +The `/json-cors-origin/*` route in `app.js` (lines 78–92) builds its CORS response by **reflecting whatever `Origin` header the requester sends** back as `Access-Control-Allow-Origin`, and unconditionally sets `Access-Control-Allow-Credentials: true`: + +```js +if (req.get("Origin") != null) { + res.setHeader('Access-Control-Allow-Origin', req.get("Origin")); +} else { + res.setHeader('Access-Control-Allow-Origin', "https://" + req.hostname + ":3000"); +} +res.setHeader('Access-Control-Allow-Credentials', 'true'); +``` + +This is the canonical CORS misconfiguration: reflect-origin + allow-credentials together mean *any* website on the internet can make a `fetch`/XHR request with `credentials: 'include'` and have the browser both send the victim's session cookie **and** allow the JS to read the response. There is no origin allow-list, so the Same-Origin Policy provides zero protection here. Compounding it, the session cookie (`superSecretSession`, set in `app.js:105` and again client-side in `views/cors/allow-origin/webapp.ejs`) is **not `HttpOnly`** and uses `SameSite=None`, so it's both readable by JS and sent on cross-site requests — there's no secondary defense layer (no `SameSite=Lax/Strict`, no CSRF token, no per-origin allow-list) to fall back on once the CORS check is bypassed. + +### 2. How could an attacker get the user to visit the malicious blog? + +Realistic delivery vectors for a logged-in NC user, any of which just need the victim's browser to load attacker-controlled JS while the session cookie is still valid (no interaction with the NC app itself required): +- **Phishing / social engineering**: an email, SMS, or social media post ("read this health article") linking to the attacker's page. +- **Malvertising**: a paid ad network serving the attacker's page/script on otherwise legitimate sites. +- **Compromised third-party content**: a supply-chain compromise of a JS library, widget, or ad tag embedded in a site the victim already trusts (the victim never has to knowingly visit "evil.com" — the payload can be injected into a page they already trust). +- **Open redirect / link shortener abuse**: hiding the true destination behind a trusted-looking shortened or redirected URL. +- **Watering hole**: compromising a site frequented by the target demographic (e.g. a fertility/wellness forum) and injecting the theft script there. + +None of these require compromising Natural Cycles' own infrastructure — the vulnerability lives entirely in the CORS/cookie configuration, so *any* page the victim's browser loads while authenticated is a viable delivery mechanism. + +### 3. How can this attack be prevented? + +In priority order: +1. **Never reflect `Origin` when `Access-Control-Allow-Credentials: true`.** Maintain an explicit allow-list of known, trusted origins (e.g. `https://app.naturalcycles.com`) and only set `Access-Control-Allow-Origin` to the request's `Origin` if it matches an entry in that list — otherwise omit the header entirely. +2. **Make the session cookie `HttpOnly`** so client-side JS (attacker's or otherwise) can never read it directly, and set **`SameSite=Lax` or `Strict`** so it isn't attached to cross-site requests at all in modern browsers — this alone would have blocked this specific attack even with the CORS misconfiguration in place. +3. **Prefer token-based auth for API calls** (e.g. short-lived bearer tokens sent explicitly by client code) over ambient cookie auth for endpoints that must be reachable cross-origin — tokens aren't automatically attached the way cookies are. +4. **Defense in depth**: CSRF tokens on state-changing requests, `Vary: Origin` (already present) to avoid cache poisoning across origins, and monitoring for the "credentialed request from unlisted Origin" signature (see Part 4). + +--- + +## Part 2: X-Forwarded-For IP Spoofing + +Full reproduction and captured output is in **`evidence/xff-spoof-repro.md`**. + +### 1. Is the audit logging vulnerable to XFF spoofing? + +**Yes, confirmed.** `app.js:14` sets `app.set('trust proxy', true)`, which tells Express to trust *every* hop declared in `X-Forwarded-For` and take the left-most (client-supplied) address as `req.ip` — used directly in the audit log at `app.js:111`. Sending `curl` requests with an arbitrary `X-Forwarded-For: 6.6.6.6` (or a fabricated multi-hop chain) causes that exact value to appear as the "received from" IP, with no real proxy in the path validating it. An attacker can make every request appear to originate from any IP they choose, defeating both the audit trail and any IP-based controls built on `req.ip`. + +### 2. Fix for a GCP + Load Balancer deployment + +The core problem is `trust proxy: true` trusts an *unbounded* number of hops. The fix is to trust **only the exact number of hops that are actually real infrastructure**, not a blanket `true`: + +- On Cloud Run behind an external HTTPS Load Balancer, the request path is: `client -> GCLB -> Cloud Run's own front end -> your container`. GCLB appends the true client IP to `X-Forwarded-For` before forwarding; Cloud Run's ingress may append again. That means there are a small, fixed number of *trusted* hops closest to the app — set `app.set('trust proxy', N)` to that exact count (verified against current GCP documentation for Cloud Run + LB) rather than `true`. With a numeric hop count, Express walks in from the right and only trusts that many entries, so anything the original client injected into `X-Forwarded-For` themselves gets correctly ignored. +- Stronger option: don't parse `X-Forwarded-For` for trust decisions at all. Put a **Cloud Armor security policy** in front of the Load Balancer and have it inject a header GCLB/Cloud Armor controls (Cloud Armor exposes the verified client IP it evaluated its rules against); trust that header unconditionally since the client can never write to it (Cloud Armor/GCLB overwrite it at the edge, stripping anything the client sent under that name). +- Either way: **never leave `trust proxy` as the boolean `true`** in a production deployment — it silently trusts an unbounded, attacker-influenced chain. + +### 3. IP-based rate limiting recommendation, pros & cons + +**Recommendation: rate-limit at the edge, in Cloud Armor, not in the app.** + +Configure a Cloud Armor **rate-based ban rule** on the Load Balancer, keyed on the client IP as GCLB itself observed the TCP connection (never on a header value) — e.g. threshold N requests/min per IP, then temporary ban. + +| | Edge rate limiting (Cloud Armor) | App-level rate limiting (e.g. `express-rate-limit` keyed on `req.ip`) | +|---|---|---| +| **Pros** | Sees the *real* client IP directly from the TCP/TLS connection — immune to header spoofing; blocks traffic before it consumes app/container resources or scales up Cloud Run instances (cost control); scales independently of the app; centrally managed across all services behind the LB | Can implement business-logic-aware limits (e.g. per authenticated user/API key, not just per IP; different limits per route) | +| **Cons** | Coarser-grained — can't easily key on application concepts like "logged-in user ID"; another GCP resource to configure/pay for | Only as trustworthy as the `trust proxy` configuration behind it (must be fixed per above); consumes app compute for requests that get limited anyway; a single Cloud Run instance's in-memory limiter doesn't share state across autoscaled instances without an external store (e.g. Redis) | + +**Best practice**: use both, in layers — Cloud Armor for coarse, IP-based, pre-app protection (immune to spoofing once `X-Forwarded-For`/trust proxy is fixed per above), and app-level limiting for finer-grained per-user/per-API-key logic where IP alone isn't the right key. + +--- + +## Part 3: Data Pipeline for Logs (ELK SIEM, Cloud Run) + +**Assumptions**: app is deployed on Google Cloud Run; SIEM is a self-managed ELK stack (Elasticsearch + Logstash/Kibana, not Elastic Cloud). + +**Design:** + +``` +Cloud Run container (stdout/stderr, structured JSON logs) + | (automatic — Cloud Run always forwards container stdout/stderr) + v + Google Cloud Logging + | (Log Router: a Cloud Logging *sink* with an inclusion filter, + | e.g. resource.type="cloud_run_revision" AND relevant severity/labels) + v + Pub/Sub topic (+ a dead-letter topic for failed deliveries) + | + v + Logstash (or Elastic Agent's GCP Pub/Sub input) subscribed to the topic + | (parse/enrich: geoip on client IP, field renames, drop noisy fields) + v + Elasticsearch (ILM policy: hot/warm/cold + retention matching compliance needs) + | + v + Kibana (SIEM detections, dashboards, alerting) +``` + +**Why this shape:** +- **No agent to install** on Cloud Run itself — Cloud Run's platform already captures stdout/stderr into Cloud Logging automatically, so the app only needs to log **structured JSON** (not free-text) so fields like `origin`, `sourceIp`, `path`, `cookiePresent` are queryable without regex parsing downstream. +- **Cloud Logging sink -> Pub/Sub** is the standard GCP-native export path and decouples "how logs are produced" from "how ELK consumes them" — if Logstash/ES is down or being upgraded, Pub/Sub retains messages (configurable retention, e.g. 7 days) instead of losing them, and a dead-letter topic catches anything Logstash can't process after N delivery attempts, so malformed events don't silently vanish. +- **Reliability**: Pub/Sub gives at-least-once delivery with retries; combine with Logstash's persistent queue (or the Elastic Agent's own on-disk buffering) so a Logstash restart doesn't drop in-flight messages. Idempotent ingestion (e.g. a stable `_id` for dedup) handles the at-least-once (rather than exactly-once) delivery semantics. +- **Scalability**: Cloud Run autoscaling and Pub/Sub both scale independently of each other and of Elasticsearch — a traffic spike doesn't require scaling ES in lockstep; Pub/Sub simply buffers until Logstash/ES catches up. Logstash workers can be scaled horizontally (multiple consumers on the same subscription) if ingest volume grows. +- **Cost/relevance filtering**: apply the inclusion filter at the Cloud Logging sink stage (not "ship everything") so only security-relevant log entries (auth events, CORS/audit log lines, 4xx/5xx, admin actions) cross into the pipeline, keeping ES storage and Logstash CPU proportional to what's actually useful for detection. +- **Sensitive data**: since some of this app's payloads are PII-shaped (health/reproductive data per the demo's fixtures), redact or tokenize sensitive fields either at the app's structured-logging layer or in a Logstash filter *before* the data lands in Elasticsearch, not after. + +--- + +## Part 4: Identifying the Attack Through Log Search + +**Given the pipeline from Part 3, how would you search for the Part 1 attack?** + +**Answer: a hybrid — generic infrastructure logs as the reliable base signal, enriched with a small number of targeted fields logged by the app itself, not full bespoke in-app detection logic.** + +Rationale (per the hint): +- **Pure generic logging** (just HTTP access logs: method, path, status, IP, user-agent) is always-on, requires zero app changes, and can't be bypassed by a bug in app-level detection code — but it can't see the actual CORS-theft signature at all, because the interesting fact ("this credentialed request's `Origin` doesn't match our own domain, and we reflected it back") lives in header *semantics* that generic access logs don't usually capture verbatim (many LB/proxy access log formats don't log the `Origin` request header or the `Access-Control-Allow-Origin` response header by default). +- **Pure in-app detection** (e.g. hand-rolled logic buried in a route handler) is fragile: easy to accidentally bypass with a code change elsewhere, adds attack-surface-specific logic that has to be re-implemented per route/service, and if the detection logic itself has a bug, you silently lose visibility with no generic fallback. +- **The practical middle ground**: keep detection *data* generic (log the handful of raw header values at the access-log/app-log layer — `Origin`, `Referer`, whether a session cookie was present, the actual `Access-Control-Allow-Origin` value the server sent back, `User-Agent`, source IP, path, status) and build the *detection query/alert* in the SIEM (Kibana) as a saved search/detection rule over that generic data, rather than hardcoding "if this looks like an attack, alert" inside `app.js`. This keeps the app's job simple (log good structured facts) and keeps the actual detection logic centralized, versioned, and adjustable in the SIEM without a code deploy. + +**Specific data points useful for detecting this attack:** +- Requests to `/json-*` (or any authenticated API) where the `Origin` header is present **and** does not match the known set of first-party origins. +- Whether a session/auth cookie was present on that same request (i.e. it was a *credentialed* cross-origin request, not just any cross-origin hit). +- The actual `Access-Control-Allow-Origin` value the server sent back — if it equals the request's `Origin` (reflection) rather than a fixed value, that's the smoking gun of the misconfiguration being actively exploited, not just probed. +- Volume/burst pattern: a spike of distinct, previously-unseen `Origin` values hitting the same authenticated endpoint in a short window (mass/automated exploitation vs. a one-off manual test). +- Correlate by session/cookie value across origins: the *same* session cookie being presented from multiple, unrelated `Origin` values in a short time window is a strong indicator the session has been stolen and replayed, independent of whether this specific CORS bug is the vector. + +--- + +## Part 5: Operationalizing Attack Response on Slack + +Implementation writeup is in **`slackbot/README.md`**; end-to-end evidence (console logs + DB +states from a real attack) is in **`evidence/part5-slackbot-repro.md`**. + +**What was built** (`slackbot/`, a standalone Node service): +- A **Bolt** app in **Socket Mode** — connects to Slack with just a Bot Token + App-Level Token, + no public URL/ngrok, so it runs entirely on localhost. +- **Real trigger, not a fake button**: `app.js`'s vulnerable `/json-cors-origin/*` route now also + detects the Part 1 attack signature — a *credentialed cross-origin request whose `Origin` isn't + one of the app's own* — and fires a fire-and-forget notification to the bot's localhost-only + `POST /internal/alert` endpoint. So the Slack alert is a genuine side-effect of the exploit + happening. +- **Interactive Block Kit message** with three buttons — *Cyber attack*, *Infrastructure + instability*, *False positive* — matching the PDF's example. +- **On classification**: the bot records who classified it (Slack user ID) and when, then + `chat.update`s the message in place to a **discreet resolved state** + (`✅ Confirmed cyber attack · marked by at