diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd36808..57035c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,17 @@ on: branches: - main +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: checks: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 @@ -36,3 +44,6 @@ jobs: - name: Build run: pnpm build + + - name: Validate Worker bundle + run: pnpm exec wrangler deploy --dry-run --outdir .wrangler/dry-run diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7b7bca..f3a7d04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,7 @@ pnpm dev Before opening a PR, run: ```bash -pnpm lint -pnpm typecheck -pnpm test -pnpm build +pnpm check ``` ## Branch and PR Workflow @@ -55,7 +52,7 @@ pnpm build ## Community PR Checklist -- [ ] Lint/typecheck/test/build pass locally +- [ ] `pnpm check` passes locally, including the Worker bundle dry-run - [ ] New behavior covered by tests - [ ] README/docs updated if needed - [ ] No breaking API/route changes without notes diff --git a/PLAN.md b/PLAN.md index a3e7720..91ff5b0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -148,8 +148,13 @@ - Deployment risk: explicit Wrangler asset + route config and CI verification. ## Definition of Done -- App runs locally and builds cleanly. -- Cloudflare Worker serves SPA with `/api/*` routes and SPA fallback. -- Universal Inspector + all MVP tools function. -- Tests pass, CI passes, deploy workflow ready. -- README and security/privacy notes complete. +- [x] App runs locally and builds cleanly. +- [x] Cloudflare Worker serves SPA with `/api/*` routes and SPA fallback. +- [x] Universal Inspector and specialized tool suite function. +- [x] Heavy routes are lazy-loaded and large byte views are paged. +- [x] Local file hex viewer includes accessible byte color, search, statistics, and structure maps. +- [x] Binary diff supports synchronized local-file comparison. +- [x] Reusable recipe pipelines expose deterministic intermediate results without persisting payloads. +- [x] Installable offline shell bypasses all API traffic. +- [x] Network diagnostics validate targets and redirects; APIs enforce size/type/rate safeguards. +- [x] Tests, CI, SAST/DAST workflows, OpenAPI, and privacy/security docs are present. diff --git a/README.md b/README.md index be1db54..aca9d76 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,11 @@ Desktop - HTTP Signer: - URL and HTML entity encode/decode - Unicode inspection (code points, UTF-8 bytes, UTF-16 units) - JWT inspector with decode warnings and claim highlighting -- Hexdump formatter with offsets and ASCII preview +- Local-file hex viewer with byte-spectrum coloring, search/jump, paging, statistics, and synchronized ASCII +- Clickable binary structure maps for PNG, JPEG, GIF, ZIP, ELF, PE, and PDF files +- Binary/payload diff with two local files, byte-level runs, change navigation, and synchronized selection +- Reusable local recipe pipelines with presets, intermediate values, versioned import/export, and definition-only local saves +- Installable offline app shell; API traffic and payload data are never service-worker cached - Hash + HMAC helpers via Web Crypto - Bitwise operations, endianness swap, IPv4/int conversion, timestamp conversion - Entropy, stats, frequency hints, magic byte detection @@ -48,14 +52,17 @@ Desktop - HTTP Signer: - `tests`: Vitest unit tests for deterministic modules Core transforms run client-side by default. The Worker is intentionally lean and future-ready. +Every specialized route is lazy-loaded, so opening Hexyr does not download every parser. ## Privacy Model -- Payload transforms are client-side in MVP +- Browser-tool payload transforms are client-side - No backend persistence - No payload-content logging - No Cloudflare KV -- `localStorage` is used only for non-sensitive UI preferences (theme, last selected tool) +- `localStorage` is used only for non-sensitive UI preferences and recipe definitions; recipe inputs and outputs are not saved +- The service worker caches same-origin static assets only and explicitly bypasses `/api/*` +- Calling an integration API or running an opt-in network lookup sends that request to the Worker; this is visibly separate from local browser tools ## Local-First Rationale @@ -90,6 +97,8 @@ Local execution keeps interactions faster, lowers edge complexity, and reduces r - `/tool/hash` - `/tool/bitwise` - `/tool/hexdump` +- `/tool/diff` +- `/tool/recipe` - `/tool/dns` - `/tool/webhook` - `/tool/har` @@ -161,8 +170,8 @@ pnpm preview ## Deploy to Cloudflare Workers 1. Set secrets or env vars: - - `CLOUDFLARE_API_TOKEN` - - `CLOUDFLARE_ACCOUNT_ID` + - `CLOUDFLARE_API_TOKEN` + - `CLOUDFLARE_ACCOUNT_ID` 2. Authenticate Wrangler if needed: ```bash @@ -235,14 +244,16 @@ Includes deterministic coverage for encoding, detection, parsing, entropy, magic - JWT decode is **not** signature verification. - Do not treat decoded JWT payloads as trusted unless signature and claims are validated in your own auth context. - Avoid pasting production secrets into third-party tools; Hexyr is built to keep this local-first. +- Worker APIs require JSON, cap request bodies at 1 MiB, return `no-store`, use defense-in-depth security headers, and rate-limit tool calls per runtime instance. +- Network probes reject local/private/reserved targets, credentials, nonstandard ports, unsafe schemes, and unsafe redirect destinations. +- The in-memory Worker limiter is best-effort per isolate; production abuse controls should also be configured with Cloudflare Rate Limiting. -## Roadmap Ideas +## Optional Future Extensions -- Structured compare mode for two payloads -- Optional local-only encrypted workspace snapshots -- Additional file signature and container format coverage -- Advanced command palette and keyboard workflows -- Large payload virtualization improvements +- More deep container parsers (Mach-O, WebAssembly, SQLite, PCAPNG) +- Multi-edit alignment for inserted/deleted binary ranges +- Opt-in encrypted workspace snapshots with an explicit passphrase +- Signed, shareable recipe packs that never include payload data ## License diff --git a/docs/api.md b/docs/api.md index 67698f0..ce23b67 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,6 +5,10 @@ Hexyr exposes a compact integration API on the Worker. - Base URL: `https://hexyr.com` - OpenAPI: `https://hexyr.com/openapi.json` +All POST endpoints require `Content-Type: application/json`, accept at most 1 MiB, return API +responses with `Cache-Control: no-store`, and are subject to request limiting. API calls are remote +operations; unlike browser-only tools, their request bodies are sent to the Worker. + ## Health and Discovery ### `GET /api/health` @@ -66,6 +70,9 @@ curl -s https://hexyr.com/api/tools/dns-tool \ ``` For probe-limited tools (like Ping/Trace/TCP checks), the response includes an explanatory note plus a local shell command suggestion. +Targets must be fully qualified public hostnames or public IPv4 addresses. Private/reserved +addresses, local names, credentials, unsafe schemes, custom ports, and unsafe redirect +destinations are rejected. ### `POST /api/tools/webhook-verify` diff --git a/docs/deployment.md b/docs/deployment.md index e24e73e..4085015 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -24,3 +24,6 @@ pnpm run deploy - Keep Worker logic lean and deterministic. - No KV bindings are configured. +- Configure Cloudflare Rate Limiting for `/api/tools/*`; the built-in limiter is per Worker isolate. +- Keep the CSP and private-network target protections enabled when adding new API or probe routes. +- Bump the cache name in `public/sw.js` when a service-worker caching policy changes. diff --git a/docs/getting-started.md b/docs/getting-started.md index 36b0e6e..9cb6e74 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,5 +18,14 @@ pnpm preview - `/inspect` Universal Inspector - `/tool/*` specialized developer tools +- `/tool/hexdump` local file and structured binary viewer +- `/tool/diff` binary/payload and two-file diff +- `/tool/recipe` reusable local transform pipelines - `/api/health` service status - `/api/meta` runtime metadata + +## Offline install + +Run a production build or open the deployed site once, then install Hexyr from the browser's app +menu. Static assets and tool chunks are cached as they are opened; network tools still require a +connection. diff --git a/docs/privacy-security.md b/docs/privacy-security.md index 476cba5..dcad4e2 100644 --- a/docs/privacy-security.md +++ b/docs/privacy-security.md @@ -2,10 +2,22 @@ ## Privacy model -- Transforms run client-side by default. +- Browser transforms, recipes, binary inspection, and local-file diffing run client-side. - Payload contents are not persisted server-side. -- No Cloudflare KV is used in MVP. -- `localStorage` stores only non-sensitive UI preferences. +- No Cloudflare KV or database is used. +- `localStorage` stores only non-sensitive UI preferences and recipe definitions. Recipe payloads, + intermediate values, and outputs are not stored. +- The service worker caches static same-origin app assets only. It bypasses `/api/*` and never + caches API traffic or user payloads. + +## Remote operations + +Calling `/api/*` directly sends the supplied request body to the Worker. Opt-in DNS, RDAP, and HTTP +reachability tools send the requested public target to the Worker and, where required, an upstream +resolver or registry. These paths are separate from local browser transforms. + +Do not send production secrets to an API endpoint unless that remote processing is intentional. +The browser Webhook Verifier performs its normal verification locally. ## JWT caveat @@ -15,6 +27,15 @@ JWT decoding in Hexyr is display-only and does not verify signatures. - Worker enforces HTTPS redirects. - HSTS is enabled for strict transport. +- Responses include CSP, clickjacking, MIME-sniffing, referrer, permissions, and opener/resource + isolation policies. +- Tool APIs require JSON, cap bodies at 1 MiB, set `Cache-Control: no-store`, and apply a + best-effort per-isolate request limiter. +- Network tools reject private/reserved targets, local hostnames, credentials, unsafe schemes, + custom ports, and redirects to unsafe destinations. + +For public production traffic, configure Cloudflare Rate Limiting in addition to the in-process +limiter; Worker isolates do not share process memory. ## Security automation diff --git a/docs/tools.md b/docs/tools.md index b58818e..5c8f1c7 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -9,18 +9,42 @@ Paste unknown payloads, get format detection, warnings, entropy hints, and fast - Text <-> Hex / Base64 / Binary transforms - URL encode/decode and HTML entities - Compression/decompression (gzip/deflate, auto-decompress fallback) +- Local Recipe Pipeline: + - Chain deterministic encoders, decoders, JSON steps, and gzip transforms + - Inspect every intermediate value and the character-count change at each step + - Load built-in presets, save definition-only recipes locally, or import/export versioned JSON - Formatter Lab (expand/minify/validate JSON/YAML/TOML/XML/INI/SQL/HTTP) - ASCII Art Generator (text fonts + image-to-ASCII) ## Inspection tools -- JWT inspector, Hexdump, Unicode explorer +- JWT inspector and Unicode explorer +- Hex Viewer: + - Open or drop local files without uploading them + - 18-group byte-spectrum coloring (leading nibble plus distinct `00` and `ff`) + - Semantic and color-free display modes + - Hex/text search, offset jump, paged rendering, byte statistics, and synchronized ASCII selection + - Clickable structure overlays for PNG, JPEG, GIF, ZIP, ELF, PE, and PDF containers +- Binary and Payload Diff: + - Strict text/hex/base64 decoding without silent fallback + - Two-file local comparison up to 32 MiB per side + - Byte-level change runs, synchronized selection, paging, and next/previous change navigation + +The byte-spectrum approach is inspired by Alice Pellerin's +[“your hex editor should color-code bytes”](https://simonomi.dev/blog/color-code-your-bytes/). + - X.509 inspector, TLS verifier, ASN.1/DER viewer - HAR inspector, Cookie analyzer, Schema validators - DNS Tools: - Zone formatter/validator - Lookup toolkit (MX, TXT, DMARC, SPF, RDAP/Whois, blacklist, domain health, and related DNS records) +## Offline use + +Hexyr can be installed as a PWA. The app shell and tool chunks you have opened are available +offline. The service worker does not cache API requests, API responses, or payloads. Network tools +show normal connection errors while offline; local tools continue to work. + ## Crypto and utility tools - Hash/HMAC and HTTP signer diff --git a/eslint.config.js b/eslint.config.js index d80dfc3..dc1f4fd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,5 +32,11 @@ export default tseslint.config( globals: globals.serviceworker, }, }, + { + files: ['public/sw.js'], + languageOptions: { + globals: globals.serviceworker, + }, + }, prettier, ); diff --git a/index.html b/index.html index 446582d..858b9b1 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ + + https://hexyr.com/tool/pcap https://hexyr.com/tool/compress https://hexyr.com/tool/batch + https://hexyr.com/tool/recipe + https://hexyr.com/tool/format + https://hexyr.com/tool/ascii https://hexyr.com/tool/redact https://hexyr.com/tool/replay + https://hexyr.com/tool/dns + https://hexyr.com/tool/webhook + https://hexyr.com/tool/har + https://hexyr.com/tool/cookies + https://hexyr.com/tool/ids + https://hexyr.com/tool/timezone + https://hexyr.com/tool/policy https://docs.hexyr.com/ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..92832dd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,71 @@ +const CACHE_VERSION = 'hexyr-shell-v2'; +const SHELL_ASSETS = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/favicon.svg', + '/icons/hexyr-mark.svg', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_VERSION) + .then((cache) => cache.addAll(SHELL_ASSETS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))), + ) + .then(() => self.clients.claim()), + ); +}); + +async function navigationResponse(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_VERSION); + await cache.put('/index.html', response.clone()); + } + return response; + } catch { + return (await caches.match('/index.html')) ?? Response.error(); + } +} + +async function staticResponse(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === 'basic') { + const cache = await caches.open(CACHE_VERSION); + await cache.put(request, response.clone()); + } + return response; +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + if ( + request.method !== 'GET' || + url.origin !== self.location.origin || + url.pathname.startsWith('/api/') + ) { + return; + } + if (request.mode === 'navigate') { + event.respondWith(navigationResponse(request)); + return; + } + if (['script', 'style', 'font', 'image', 'manifest', 'worker'].includes(request.destination)) { + event.respondWith(staticResponse(request)); + } +}); diff --git a/src/app/App.tsx b/src/app/App.tsx index a3ddb97..2b2253c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,21 +1,24 @@ import { useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; +import { AppErrorBoundary } from './components/AppErrorBoundary'; import { CommandPalette } from './components/CommandPalette'; import { MobileToolsDrawer } from './components/MobileToolsDrawer'; +import { OfflineStatus } from './components/OfflineStatus'; import { TopBar } from './components/TopBar'; import { WorkspaceProvider } from './hooks/useWorkspace'; import { AppRoutes } from './routes/AppRoutes'; +import { readLocalSetting, writeLocalSetting } from './utils/storage'; const THEME_KEY = 'hexyr:theme'; export default function App() { - const [dark, setDark] = useState(() => localStorage.getItem(THEME_KEY) !== 'light'); + const [dark, setDark] = useState(() => readLocalSetting(THEME_KEY) !== 'light'); const [paletteOpen, setPaletteOpen] = useState(false); const [mobileToolsOpen, setMobileToolsOpen] = useState(false); useEffect(() => { document.documentElement.classList.toggle('light', !dark); - localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); + writeLocalSetting(THEME_KEY, dark ? 'dark' : 'light'); }, [dark]); useEffect(() => { @@ -43,7 +46,9 @@ export default function App() { onOpenPalette={() => setPaletteOpen(true)} onOpenTools={() => setMobileToolsOpen(true)} /> - + + + setPaletteOpen(false)} /> setMobileToolsOpen(false)} /> + diff --git a/src/app/components/AppErrorBoundary.tsx b/src/app/components/AppErrorBoundary.tsx new file mode 100644 index 0000000..41e26f4 --- /dev/null +++ b/src/app/components/AppErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { AlertTriangle, RotateCcw } from 'lucide-react'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface AppErrorBoundaryState { + failed: boolean; +} + +export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErrorBoundaryState> { + state: AppErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): AppErrorBoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + if (import.meta.env.DEV) { + console.error('Hexyr route failed', error, info.componentStack); + } + } + + render() { + if (!this.state.failed) return this.props.children; + return ( +
+
+ +

This tool could not open

+

+ Your input was not uploaded or persisted. Reload Hexyr to restore a clean local + workspace. +

+ +
+
+ ); + } +} diff --git a/src/app/components/CommandPalette.tsx b/src/app/components/CommandPalette.tsx index 4696959..fd05740 100644 --- a/src/app/components/CommandPalette.tsx +++ b/src/app/components/CommandPalette.tsx @@ -10,6 +10,7 @@ interface CommandPaletteProps { export function CommandPalette({ open, onClose }: CommandPaletteProps) { const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); const navigate = useNavigate(); const { setActiveTool } = useWorkspace(); @@ -27,6 +28,12 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { ); }, [query]); + const closePalette = () => { + setQuery(''); + setSelectedIndex(0); + onClose(); + }; + if (!open) { return null; } @@ -34,7 +41,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { return (
setQuery(event.target.value)} + onChange={(event) => { + setQuery(event.target.value); + setSelectedIndex(0); + }} onKeyDown={(event) => { if (event.key === 'Escape') { - onClose(); + closePalette(); + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelectedIndex((index) => (index + 1) % Math.max(1, filtered.length)); + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedIndex( + (index) => (index - 1 + Math.max(1, filtered.length)) % Math.max(1, filtered.length), + ); + } + if (event.key === 'Home') { + event.preventDefault(); + setSelectedIndex(0); + } + if (event.key === 'End') { + event.preventDefault(); + setSelectedIndex(Math.max(0, filtered.length - 1)); } - if (event.key === 'Enter' && filtered[0]) { - setActiveTool(filtered[0].key); - navigate(filtered[0].route); - onClose(); + const selected = filtered[selectedIndex]; + if (event.key === 'Enter' && selected) { + setActiveTool(selected.key); + navigate(selected.route); + closePalette(); } }} + role="combobox" + aria-expanded="true" + aria-controls="command-palette-results" + aria-activedescendant={filtered[selectedIndex] ? `command-${filtered[selectedIndex].key}` : undefined} className="focus-ring w-full rounded-md border border-white/10 bg-surface-800 px-3 py-2 font-mono text-sm text-slate-100" placeholder="Search tools (hex, jwt, hash, inspector)" /> -
- {filtered.map((tool) => ( +
+ {filtered.map((tool, index) => (
-