diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c80522c..070bcf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - workflow_dispatch: permissions: contents: read @@ -19,6 +18,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: + # Keep CI within the range declared in package.json engines. node-version: 20.19 check-latest: true cache: npm @@ -26,6 +26,9 @@ jobs: - name: Install run: npm ci + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + - name: Typecheck run: npm run typecheck diff --git a/README.md b/README.md index 22a4fbd..45e1f8a 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,21 @@ drop in cleanly. | **JWT Inspector** | A02 | Decodes a JWT in the browser (tokens never leave your tab), audits the header & payload (`alg:none`, `kid` injection, `jku`/`x5u`, embedded `jwk`, expired/long-lived tokens, missing `iss`/`aud`/`sub`, sensitive payload claims), verifies HMAC signatures via Web Crypto, and runs a built-in wordlist crack for HS256/384/512. | | **CORS Tester** | A05 | Sends 8 Origin probes — baseline, arbitrary cross-origin, `null`, suffix bypass, prefix bypass, subdomain trust, scheme downgrade, OPTIONS preflight — and grades the response. Flags the dangerous reflection-with-credentials pattern. | | **TLS / Cert Viewer** | A02 | Two-pass `node:tls` handshake (one strict for the trust verdict, one lenient for the chain). Walks the issuer chain, parses signature algorithm via a tiny built-in DER reader, and grades expiry tiers, hostname match, weak signature/key algorithms, protocol version, and cipher. | +| **Security.txt Auditor** | RFC 9116 | Checks `/.well-known/security.txt` and `/security.txt`, parses disclosure-program fields, validates required Contact and Expires values, flags canonical mismatch, and reports malformed lines. | -More tools planned (open-redirect tester, subdomain hygiene check, robots/sitemap -auditor, JOSE algorithm confusion exerciser, etc.). +More tools planned (DNS/email security checker, CSP analyzer, open-redirect tester, +robots/sitemap auditor, JOSE algorithm confusion exerciser, etc.). + +--- + +## What this demonstrates + +- Next.js App Router with server/client route boundaries and strict TypeScript. +- SSRF-aware server-side scanning with redirect re-checks and blocked private address space. +- Defensive API design: input caps, rate limits, bounded probes, and no third-party APIs or secrets. +- Security-specific parsing and grading for JWTs, CORS behavior, TLS certificates, HTTP headers, cookies, exposed files, and RFC 9116 `security.txt` files. +- Reusable finding/report components with evidence, remediation guidance, and severity scoring. +- CI-friendly quality gates: typecheck, lint, test, and production build. --- @@ -57,9 +69,9 @@ common path: 1. Push the repo to GitHub (or fork this one). 2. Go to [vercel.com/new](https://vercel.com/new) and import the repo. - Next.js 15 is auto-detected — leave the build/output settings on default. + Next.js 16 is auto-detected — leave the build/output settings on default. 3. Click **Deploy**. First build takes ~30 seconds. -4. Visit your `*.vercel.app` URL. All four tools should work immediately. +4. Visit your `*.vercel.app` URL. All five tools should work immediately. Optional: set `SITE_URL` in Vercel's environment-variables panel to your final custom domain (e.g. `https://cybertoolbox.example.com`). It feeds `robots.txt`, @@ -71,10 +83,10 @@ to Vercel's `VERCEL_URL`. | Concern | Handled by | | --- | --- | | Function runtimes | Each API route exports `runtime = "nodejs"` (needed for DNS resolution in the SSRF guard and `node:tls` for the cert viewer). | -| Function timeouts | `vercel.json` sets `maxDuration` per route (15 s misconfig + cert, 20 s CORS) — well within the free tier's 10–60 s caps. | +| Function timeouts | `vercel.json` sets `maxDuration` per route (15 s misconfig + cert + security.txt, 20 s CORS) — well within the free tier's 10–60 s caps. | | Static security headers | `vercel.json` applies HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, and `Permissions-Policy` to every response. | -| Per-request CSP nonce | `middleware.ts` issues a fresh 128-bit nonce on every request and sets `Content-Security-Policy: ... script-src 'self' 'nonce-…' 'strict-dynamic' ...`. Next.js threads the nonce onto every inline hydration script automatically. No `'unsafe-inline'` for scripts. | -| Edge-rendered OG image | `app/opengraph-image.tsx` runs on Vercel's edge runtime via `next/og`'s `ImageResponse`. | +| Per-request CSP nonce | `proxy.ts` issues a fresh 128-bit nonce on every request and sets `Content-Security-Policy: ... script-src 'self' 'nonce-…' 'strict-dynamic' ...`. Next.js threads the nonce onto every inline hydration script automatically. No `'unsafe-inline'` for scripts. | +| Generated OG image | `app/opengraph-image.tsx` renders dynamically via `next/og`'s `ImageResponse`. | | Nice URLs / 404s | App Router file-based routing + `app/not-found.tsx` listing live tools. | After deployment, point Misconfig Mapper at your own production URL — it @@ -102,8 +114,9 @@ app/ ├─ globals.css # Tailwind base + focus ring + skip-link ├─ not-found.tsx # Custom 404 (lists live tools) ├─ robots.ts | sitemap.ts # SEO basics, runtime-rendered -├─ opengraph-image.tsx # Edge-rendered OG card via ImageResponse +├─ opengraph-image.tsx # Generated OG card via ImageResponse ├─ about/page.tsx # About page +├─ methodology/page.tsx # Defensive scope, severity, and limitations ├─ tools// │ ├─ page.tsx # Server shell, exports metadata │ └─ View.tsx # Client UI ("use client") @@ -116,6 +129,7 @@ components/ ├─ ScanReportView.tsx # Misconfig report ├─ CorsReportView.tsx # CORS probe matrix + findings ├─ CertReportView.tsx # TLS chain cards + findings +├─ SecurityTxtReportView.tsx # RFC 9116 report ├─ JsonView.tsx # Coloured JSON for JWT decoded panels └─ CopyButton.tsx # Tiny reusable copy-to-clipboard @@ -137,15 +151,19 @@ lib/ ├─ cors/ │ ├─ scan.ts # Probe set + analyzer │ └─ types.ts +├─ securitytxt/ +│ ├─ analyze.ts # RFC 9116 parser + findings +│ ├─ analyze.test.ts # Vitest coverage for parser behavior +│ └─ scan.ts # well-known/root fetch orchestration └─ tls/ ├─ scan.ts # node:tls handshake, chain walk, analysis ├─ der.ts # ASN.1 walker → signature OID └─ types.ts -middleware.ts # Per-request CSP nonce +proxy.ts # Per-request CSP nonce public/jwt-wordlist.json # Common dev/test secrets (~100 entries) vercel.json # Function maxDuration + static headers -.github/workflows/ci.yml # typecheck + lint + build on push/PR +.github/workflows/ci.yml # audit + typecheck + test + lint + build ``` --- @@ -166,22 +184,25 @@ network call: `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, `100.64.0.0/10` (CGNAT), `224.0.0.0/4` (multicast), `240.0.0.0/4` (reserved), `255.255.255.255/32`. - - **IPv6**: `::`/`::1`, `fe80::/10` (link-local), `fc00::/7` (ULA), - `ff00::/8` (multicast), and IPv4-mapped (`::ffff:…`) addresses run - through the IPv4 list. + - **IPv6**: loopback/unspecified, link-local, unique-local, site-local, + multicast, NAT64, 6to4, documentation, and IPv4-mapped ranges. 4. Hostnames `localhost` / `*.localhost` / `*.local` are rejected even if they resolve to a public IP. `safeFetch` follows redirects manually, re-applying the guard at every hop. +Each connection is restricted to the validated public address set, +closing the DNS-rebinding gap between lookup and connect. Responses are capped +at 256 KB and each request times out after 6 seconds. ### Rate limiter (`lib/security/rate-limit.ts`) -In-memory per-IP token bucket: 12 requests / minute. The map resets on a -function cold start, which is acceptable for a portfolio deploy. For real -traffic, swap in Vercel KV / Upstash Redis — `rateLimit()` is the only -function to change. +In-memory per-IP token bucket: 12 requests / minute with bounded key storage. +The map resets on a function cold start, which is acceptable for a portfolio +deploy. For strict multi-instance limits, swap in Vercel KV / Upstash Redis — +`rateLimit()` is the only function to change. API routes also require JSON and +cap each request body before parsing it. -### Per-request CSP nonce (`middleware.ts`) +### Per-request CSP nonce (`proxy.ts`) Issues a 128-bit base64 nonce per request, sets the CSP header on both the request (so Next.js's runtime threads it onto its inline scripts) and @@ -247,23 +268,25 @@ npm run dev # Next dev server with HMR npm run build # Production build npm run start # Production server (after build) npm run typecheck # tsc --noEmit -npm run lint # next lint +npm run lint # ESLint +npm run test # Vitest unit tests ``` -CI (`.github/workflows/ci.yml`) runs `typecheck` + `lint` + `build` on every -push and pull request. +CI (`.github/workflows/ci.yml`) audits production dependencies and runs +`typecheck` + `lint` + `test` + `build` on every push and pull request. --- ## Stack -- **Next.js 15** (App Router) on **Vercel** +- **Next.js 16** (App Router) on **Vercel** - **TypeScript 5** strict mode - **Tailwind 3** with a small dark palette (`ink-*` + `accent-*`) - **Web Crypto API** for the JWT crack (no Node crypto in the browser) - **`node:tls` + a tiny ASN.1 DER walker** for the cert viewer +- **Vitest** for fast unit coverage of pure security parsers and graders - **No third-party JS deps** outside Next/React/Tailwind. Everything else - is hand-rolled and visible in `lib/`. + shipped to the browser is hand-rolled and visible in `lib/`. --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a146bab --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Defensive use only + +Cyber Toolbox is built for authorized security testing, education, and portfolio demonstration. Do not use it to scan systems you do not own or do not have explicit permission to test. + +The hosted tools intentionally avoid aggressive behavior. They perform bounded checks such as header inspection, TLS handshakes, CORS probes, and standards-based metadata audits. They are not vulnerability exploitation tools. + +## Reporting vulnerabilities + +If you find a vulnerability in Cyber Toolbox itself, please report it privately instead of opening a public issue with exploit details. + +Preferred contact: `tinkthemaker@proton.me` + +Please include: + +- A clear description of the issue +- Steps to reproduce +- Affected route or component +- Potential impact +- Any suggested remediation + +## Supported version + +This is a portfolio project. The `main` branch is the supported version. + +## Security design notes + +The app includes several guardrails: + +- Server-side URL scans pass through an SSRF guard and connect only to validated public addresses. +- Redirects are followed manually and re-checked at each hop. +- Private, loopback, link-local, multicast, reserved, cloud metadata, `.local`, and `localhost` targets are blocked. +- API routes cap JSON request bodies and use a bounded per-IP in-memory rate limiter. +- The app sets static security headers through `vercel.json`. +- A per-request CSP nonce is generated in the application proxy for inline Next.js scripts. + +These controls reduce abuse risk, but they do not make unauthorized scanning acceptable. diff --git a/app/api/tools/cert-viewer/route.ts b/app/api/tools/cert-viewer/route.ts index b6617a3..5366f34 100644 --- a/app/api/tools/cert-viewer/route.ts +++ b/app/api/tools/cert-viewer/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { runTlsScan } from "@/lib/tls/scan"; import { rateLimit, clientKeyFromHeaders } from "@/lib/security/rate-limit"; +import { readJsonRequest } from "@/lib/security/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,14 +17,12 @@ export async function POST(req: Request) { ); } - let payload: unknown; - try { - payload = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + const parsed = await readJsonRequest(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); } - const host = (payload as { host?: unknown })?.host; + const host = (parsed.value as { host?: unknown })?.host; if (typeof host !== "string" || host.length === 0 || host.length > 1024) { return NextResponse.json( { error: "Provide a 'host' string (e.g. example.com or example.com:443)." }, diff --git a/app/api/tools/cors-tester/route.ts b/app/api/tools/cors-tester/route.ts index 4bef863..b65adbe 100644 --- a/app/api/tools/cors-tester/route.ts +++ b/app/api/tools/cors-tester/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { runCorsScan } from "@/lib/cors/scan"; import { rateLimit, clientKeyFromHeaders } from "@/lib/security/rate-limit"; +import { readJsonRequest } from "@/lib/security/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,14 +17,12 @@ export async function POST(req: Request) { ); } - let payload: unknown; - try { - payload = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + const parsed = await readJsonRequest(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); } - const url = (payload as { url?: unknown })?.url; + const url = (parsed.value as { url?: unknown })?.url; if (typeof url !== "string" || url.length === 0 || url.length > 2048) { return NextResponse.json({ error: "Provide a 'url' string (max 2048 chars)." }, { status: 400 }); } diff --git a/app/api/tools/misconfig-mapper/route.ts b/app/api/tools/misconfig-mapper/route.ts index 5ac7ada..093ead0 100644 --- a/app/api/tools/misconfig-mapper/route.ts +++ b/app/api/tools/misconfig-mapper/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { runScan } from "@/lib/misconfig/scan"; import { rateLimit, clientKeyFromHeaders } from "@/lib/security/rate-limit"; +import { readJsonRequest } from "@/lib/security/request"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,14 +17,12 @@ export async function POST(req: Request) { ); } - let payload: unknown; - try { - payload = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + const parsed = await readJsonRequest(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); } - const url = (payload as { url?: unknown })?.url; + const url = (parsed.value as { url?: unknown })?.url; if (typeof url !== "string" || url.length === 0 || url.length > 2048) { return NextResponse.json({ error: "Provide a 'url' string (max 2048 chars)." }, { status: 400 }); } diff --git a/app/api/tools/securitytxt-auditor/route.ts b/app/api/tools/securitytxt-auditor/route.ts new file mode 100644 index 0000000..63e7884 --- /dev/null +++ b/app/api/tools/securitytxt-auditor/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { clientKeyFromHeaders, rateLimit } from "@/lib/security/rate-limit"; +import { readJsonRequest } from "@/lib/security/request"; +import { runSecurityTxtAudit } from "@/lib/securitytxt/scan"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 15; + +export async function POST(req: Request) { + const key = clientKeyFromHeaders(new Headers(req.headers)); + const rl = rateLimit(key); + if (!rl.ok) { + return NextResponse.json( + { error: `Rate limit exceeded. Try again in ${rl.retryAfterSec}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } }, + ); + } + + const parsed = await readJsonRequest(req); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + } + + const url = (parsed.value as { url?: unknown })?.url; + if (typeof url !== "string" || url.length === 0 || url.length > 2048) { + return NextResponse.json({ error: "Provide a 'url' string (max 2048 chars)." }, { status: 400 }); + } + + const result = await runSecurityTxtAudit(url); + if (!result.ok) { + return NextResponse.json({ error: result.reason }, { status: 400 }); + } + return NextResponse.json(result.report); +} diff --git a/app/layout.tsx b/app/layout.tsx index adc7bea..7add47f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,11 @@ import type { Metadata } from "next"; import Link from "next/link"; import { headers } from "next/headers"; -import { SITE } from "@/lib/tools/registry"; +import { baseUrl, SITE } from "@/lib/tools/registry"; import "./globals.css"; export const metadata: Metadata = { + metadataBase: new URL(baseUrl()), title: { default: SITE.name, template: `%s · ${SITE.name}`, @@ -43,6 +44,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo diff --git a/app/methodology/page.tsx b/app/methodology/page.tsx new file mode 100644 index 0000000..c18179e --- /dev/null +++ b/app/methodology/page.tsx @@ -0,0 +1,85 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Methodology", + description: "How Cyber Toolbox scopes checks, assigns severity, and limits abuse risk.", +}; + +const sections = [ + { + title: "Defensive scope", + body: + "Cyber Toolbox is designed for public, authorized checks that help owners understand basic web security posture. It does not crawl sites, brute force services, exploit findings, or attempt authentication bypass.", + }, + { + title: "Network safety", + body: + "Every server-side URL check is routed through an SSRF guard. The guard resolves hostnames before fetches, blocks private and reserved address space, rejects localhost-style names, and re-applies the same checks after redirects.", + }, + { + title: "Rate limiting", + body: + "API routes use a small in-memory per-IP limiter. It is intentionally simple for a portfolio deployment and can be swapped for Vercel KV or Upstash Redis if the project needs durable shared limits.", + }, + { + title: "Severity model", + body: + "Findings are graded as pass, info, warn, or fail. Fail means a missing or dangerous control. Warn means a weakness, ambiguity, stale value, or risky configuration. Info points out useful optional improvements without penalizing the target heavily.", + }, + { + title: "Evidence-first reports", + body: + "Reports favor concrete evidence: the exact header, policy field, certificate detail, response behavior, or parsed token claim that led to the finding. Recommendations are written as remediation guidance instead of generic warnings.", + }, + { + title: "Known limitations", + body: + "These tools provide focused checks, not a full security assessment. A clean report does not prove an application is secure. Network errors, CDN behavior, redirects, and intentionally unusual policies can also affect results.", + }, +]; + +const references = [ + ["OWASP Top 10", "https://owasp.org/www-project-top-ten/"], + ["RFC 9116: security.txt", "https://www.rfc-editor.org/rfc/rfc9116"], + ["MDN: Content Security Policy", "https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP"], + ["CISA Known Exploited Vulnerabilities", "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"], + ["NIST SP 800-53", "https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final"], +]; + +export default function MethodologyPage() { + return ( +
+
+

Project methodology

+

How the toolbox thinks

+

+ Cyber Toolbox is intentionally narrow: small tools, bounded checks, clear evidence, and + defensive guardrails. The goal is to make security posture easier to discuss without + pretending that automated checks replace a real assessment. +

+
+ +
+ {sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+ +
+

References

+
    + {references.map(([label, href]) => ( +
  • + + {label} + +
  • + ))} +
+
+
+ ); +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index d0fc032..c8d8a1f 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -1,7 +1,7 @@ import { ImageResponse } from "next/og"; import { SITE, TOOLS } from "@/lib/tools/registry"; -export const runtime = "edge"; +export const runtime = "nodejs"; export const alt = SITE.name; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; diff --git a/app/tools/jwt-inspector/View.tsx b/app/tools/jwt-inspector/View.tsx index ee5ebae..5f18353 100644 --- a/app/tools/jwt-inspector/View.tsx +++ b/app/tools/jwt-inspector/View.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { parseJwt } from "@/lib/jwt/parse"; import { analyzeJwt } from "@/lib/jwt/analyze"; import { crackHmac, isHmacAlg, verifyWithSecret } from "@/lib/jwt/crack"; @@ -65,16 +65,19 @@ export default function JwtInspectorPage() { | { kind: "error"; message: string } >({ kind: "idle" }); const wordlistRef = useRef(null); + const operationIdRef = useRef(0); const parsed = useMemo(() => parseJwt(input), [input]); const findings = useMemo(() => (parsed.ok ? analyzeJwt(parsed.jwt) : []), [parsed]); const alg = parsed.ok ? (parsed.jwt.header as { alg?: unknown }).alg : undefined; const showHmacTools = parsed.ok && isHmacAlg(alg); - useEffect(() => { + function updateInput(value: string) { + operationIdRef.current += 1; + setInput(value); setVerifyState({ kind: "idle" }); setCrackState({ kind: "idle" }); - }, [input]); + } async function loadWordlist(): Promise { if (wordlistRef.current) return wordlistRef.current; @@ -87,20 +90,26 @@ export default function JwtInspectorPage() { async function onVerify() { if (!parsed.ok || verifyState.kind === "checking") return; + const operationId = operationIdRef.current; setVerifyState({ kind: "checking" }); const r = await verifyWithSecret(parsed.jwt, secret); + if (operationId !== operationIdRef.current) return; setVerifyState({ kind: "result", ok: r.verified, reason: r.reason }); } async function onCrack() { if (!parsed.ok || crackState.kind === "running") return; + const operationId = operationIdRef.current; setCrackState({ kind: "running", tried: 0, total: 0 }); try { const list = await loadWordlist(); + if (operationId !== operationIdRef.current) return; setCrackState({ kind: "running", tried: 0, total: list.length }); const result = await crackHmac(parsed.jwt, list, (tried) => - setCrackState({ kind: "running", tried, total: list.length }), + operationId === operationIdRef.current && + setCrackState({ kind: "running", tried, total: list.length }), ); + if (operationId !== operationIdRef.current) return; setCrackState({ kind: "done", secret: result.secret, @@ -108,6 +117,7 @@ export default function JwtInspectorPage() { durationMs: result.durationMs, }); } catch (e) { + if (operationId !== operationIdRef.current) return; setCrackState({ kind: "error", message: e instanceof Error ? e.message : "crack failed" }); } } @@ -132,7 +142,7 @@ export default function JwtInspectorPage() {