auditattack-surface-2026-08-12 · v11099:sched-resume-9k2f2026-08-12served from databaseAll documents

External attack-surface audit — 2026-08-12

External attack-surface audit — 2026-08-12

Vantage: black-box, unauthenticated, external. Observation only (GET/HEAD). 25 hosts probed · 24 reachable · 4 findings · highest severity P1.

Complementary to the Friday white-box source-and-schema review. Known-accepted items (/api/1099/* ungated by design, NodeDough legacy anon key, monitoring-without-alerting) are not re-reported.


P1 — Test lane serves the ungated write API against the production database, with its own rate-limit bucket

ESCALATE NOW — this one costs money.

test.projects.jbnx.io and projects.jbnx.io are the same Railway service. The test host is publicly reachable with no authentication and reads live production data — and rate limiting, which is the only compensating control for the deliberately-ungated /api/1099/* write API, is counted per host, not per IP.

Evidence — the test lane returns this run's live production claim:

$ curl -s https://test.projects.jbnx.io/api/1099/board | grep -A3 automation
"slug":"automation"
"name":"Automation"
"status":"active"
"claimed_by":"1099:sched-resume-9k2f"      <-- claim created on projects.jbnx.io

Evidence — independent rate-limit buckets, measured back to back:

$ for i in $(seq 1 45); do curl -so/dev/null -w '%{http_code} ' https://projects.jbnx.io/api/1099/board; done
     30 200 / 15 429      (429 body: {"error":"slow down","retry_after_seconds":54})

# with the production bucket still exhausted:
$ curl -s https://test.projects.jbnx.io/api/1099/board   -> 200 (full board)

$ for i in $(seq 1 45); do curl -so/dev/null -w '%{http_code} ' https://test.projects.jbnx.io/api/1099/board; done
     29 200 / 16 429      (a separate counter, same ~30/window allowance)

All write routes are present on the test host (/claim, /status, /release, /document, /record-usage, /visit-beacon all resolve; they answer through the same rate limiter rather than 404).

What an outsider can do right now: alternate the two hostnames to get 2× the intended write rate against production, with no credential. Downstream that means forged claims (locking the agent fleet out of every slug), forged billing.work_sessions — which a paying customer is invoiced for at bill.jbnx.io — and forged handover status and published /framework/* documents on the CEO's command center.

Fix: key the limiter on client IP alone rather than host+IP, so both hostnames draw from one bucket. Better, reject non-GET /api/1099/* when Host: test.projects.jbnx.io — the test lane needs the SPA and reads to verify a deploy, not production writes.


P2 — mkt.jbnx.io: TLS hostname mismatch, and nothing behind it

The DNS record exists and points at Railway's edge, but no service is bound. Every browser gets a full-page certificate interstitial; behind it there is a 404.

$ curl -sI https://mkt.jbnx.io/
*  subject: CN=*.up.railway.app
*  subjectAltName does not match mkt.jbnx.io
*  SSL: no alternative certificate subject name matches target host name 'mkt.jbnx.io'
   -> curl exit 60, no HTTP response

$ curl -k -so/dev/null -w '%{http_code}' https://mkt.jbnx.io/     -> 404
$ curl -so/dev/null -w '%{http_code} %{redirect_url}' http://mkt.jbnx.io/  -> 301 https://mkt.jbnx.io/

Port 80 redirects into the broken HTTPS, so there is no working path to this host at all.

Impact: any campaign or link pointing at mkt.jbnx.io dies at the door, and readers are trained to click through certificate warnings — which is the habit that makes a real MITM work later. Secondary: a hostname pointed at a shared multi-tenant platform edge with no service bound is the standard subdomain-takeover precondition. Railway verifies domain ownership before binding, so takeover risk here is low but not zero.

Fix: complete the Railway custom-domain binding (needs the dashboard-only TXT record — the API returns only the CNAME), or delete the mkt.jbnx.io record until the host is actually wanted. Leaving it half-configured is the worst of the three states.


P3 — GitHub Pages hosts serve no security headers; fedm8.com returns wildcard CORS

fedm8.com, www.cipherdeck.com and nguy.in are on GitHub Pages and send none of the four headers that every Railway-hosted surface in the estate sends correctly.

$ curl -sI https://fedm8.com/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type'
   (no output)
$ curl -sI https://fedm8.com/ | grep -i access-control
access-control-allow-origin: *

Compare a Railway surface, which is exemplary:

$ curl -sI https://jbnx.io/
content-security-policy: default-src 'self'; ... frame-ancestors 'none'; object-src 'none'
strict-transport-security: max-age=63072000; includeSubDomains
x-frame-options: DENY  ·  x-content-type-options: nosniff

Impact: low but not nil. fedm8.com is the product's front door and is framable, so a hostile page can overlay its sign-in / CTA (clickjacking). No HSTS means a first-visit plain-HTTP request is strippable. The wildcard ACAO is not paired with Allow-Credentials and the site holds no session, so cross-origin read is limited to already-public marketing content.

Fix: GitHub Pages cannot set response headers. Front these three through Cloudflare and add a Transform Rule setting the four headers, or move them onto Railway alongside the rest.


P4 — bill.jbnx.io is missing Permissions-Policy

The surface that handles money and sessions sets CSP frame-ancestors, HSTS (max-age=63072000; includeSubDomains), x-content-type-options and referrer-policy, but no Permissions-Policy — while projects.jbnx.io and ai.jbnx.io, which handle less, both set it. Consistency gap rather than an exploitable flaw.

Fix: add permissions-policy: geolocation=(), microphone=(), camera=() to the bill service, matching the portal.


Verified clean

answers server: railway-hikari with no cf-ray. Unchanged and correct — POST /api/login is not being bot-challenged.

clean 429 with retry-after. The control works; P1 above is that it is scoped per host instead of per IP.

one-line JSON hint and nothing else. No stack traces, database errors, framework version banners or internal hostnames anywhere in the estate.

/static/, /assets/ or /js/ path; no credentials in HTML comments. The JWT in NodeDough's /config.js decodes to "role":"anon" — expected, and not a service-role key.

tensuite.net, 2026-10-04 — 53 days out, so nothing is inside the 30-day window.

demo.jbnx.io via meta robots; test.fedm8.com via x-robots-tag).

State change since last audit

www.jbnx.io's certificate mismatch is resolved. It now presents CN = www.jbnx.io, valid to 2026-10-24, and verifies cleanly. The jbnx-security-posture skill still lists it under §6 known-open — that row can be closed.

Not tested, and why

Login user-enumeration on bill.jbnx.io (does "wrong password" differ from "no such user") was not probed. Testing it requires deliberate failed-password attempts against a real account, which is brute-force-adjacent and risks locking out the admin account. Recommend the Friday white-box review confirm from source that both paths return an identical response and take the same time.


Black-box external audit, observation only. No DNS, certificate, header, code or configuration was modified.