Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "crowbar"
version = "0.4.1"
version = "0.5.0"
edition = "2024"
description = "A TUI web security testing proxy"
license = "MIT"
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ A terminal-based web security testing proxy built in Rust. Intercept, inspect, a
- **Editor Modes** — Choose between a standard editor and Vim-style keybindings (with normal/insert modes, motions, and operators); toggle with `F2` or set via config/CLI
- **Multi-Instance Support** — Run multiple Crowbar instances simultaneously; automatic port selection finds the next available port if the default is occupied
- **Runtime Reconfiguration** — Change the proxy bind address and scope patterns without restarting
- **Resource Limits**: Configurable caps on buffered HTTP body size, WebSocket frame size, retained history entries, and concurrent connections, with a non-loopback bind refused unless explicitly opted into via `--allow-remote`
- **Hardened Local Storage**: CA key material, sessions, rules, and config are written to `~/.crowbar` with private directory/file permissions

## Supported Platforms

Expand Down Expand Up @@ -79,8 +81,12 @@ Binaries are placed in `dist/` as `crowbar-<version>-<os>-<arch>`.
# Start the proxy (default: 127.0.0.1:8080)
crowbar

# Custom bind address
crowbar --bind 0.0.0.0:9090
# Custom loopback bind address
crowbar --bind 127.0.0.1:9090

# Remote access is an explicit opt-in because the proxy has no authentication.
# Restrict access with a host firewall or trusted private network.
crowbar --bind 0.0.0.0:9090 --allow-remote

# Start with intercept enabled
crowbar --intercept
Expand All @@ -102,8 +108,25 @@ crowbar --load ~/.crowbar/sessions/my-session.json

# Use a custom config file
crowbar --config /path/to/config.toml

# Override resource limits (values are bytes or entry/connection counts)
crowbar --max-body-bytes 10485760 \
--max-ws-frame-bytes 16777216 \
--max-history-entries 10000 \
--max-connections 128
```

Crowbar refuses a non-loopback bind unless `--allow-remote` is passed or
`allow_remote = true` is set in the configuration file. `--allow-remote` does
not enable authentication: only expose the listener on a network whose clients
and outbound access you trust. Scope patterns limit which traffic is captured;
they are not an access-control or destination policy.

The default resource limits are 10 MiB per buffered HTTP body, 16 MiB per
WebSocket frame, 10,000 retained history entries, and 128 concurrent client
connections. Lower these values for an untrusted or memory-constrained
environment. The resource-limit settings are currently CLI-only.

### CA Certificate

Crowbar generates a CA certificate on first run and stores it at `~/.crowbar/ca.pem`. Install it in your browser or system trust store to avoid TLS warnings.
Expand Down Expand Up @@ -146,6 +169,7 @@ Optional config at `~/.crowbar/config.toml`:

```toml
bind = "127.0.0.1:8080"
allow_remote = false # required for a non-loopback bind; does not add authentication
intercept = false
scope = ["*.example.com"]
editor_mode = "default" # or "vim"
Expand All @@ -155,6 +179,11 @@ proto_include = ["./third_party"] # extra import/include paths

CLI flags override config file values.

Crowbar creates `~/.crowbar` with private directory permissions and stores CA
key material, sessions, rules, configuration, and logs as private files. Keep
the generated CA key secret: anyone who obtains it can impersonate certificates
to systems that trust the Crowbar CA.

## TUI Tabs

The interface is organized into five tabs, switchable with `Tab`/`Shift+Tab` or number keys `1`–`5`. If the default port is in use, Crowbar automatically tries the next available port (up to 25 consecutive ports from the base).
Expand Down
10 changes: 6 additions & 4 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ impl App {
fn macro_send_next(&mut self) {
if self.macros.current_step >= self.macros.steps.len() {
self.macros.running = false;
self.set_status(format!("Macro complete ({} steps)", self.macros.steps.len()));
self.set_status(format!(
"Macro complete ({} steps)",
self.macros.steps.len()
));
return;
}

Expand All @@ -119,13 +122,12 @@ impl App {
let ui_tx = self.ui_tx.clone();

tokio::spawn(async move {
let ui_tx_inner = ui_tx.clone();
match repeater::send_raw_request(request).await {
Ok(resp) => {
let _ = ui_tx_inner.send(ProxyToUi::MacroResponse(step_idx, resp));
let _ = ui_tx.try_send(ProxyToUi::MacroResponse(step_idx, resp));
}
Err(e) => {
let _ = ui_tx_inner.send(ProxyToUi::MacroError(step_idx, e));
let _ = ui_tx.try_send(ProxyToUi::MacroError(step_idx, e));
}
}
});
Expand Down
52 changes: 23 additions & 29 deletions src/app/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,29 +65,23 @@ impl App {
}

fn save_session_to(&mut self, path: &std::path::Path) {
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.set_status(format!("Save failed: {}", e));
return;
}
let macro_requests: Vec<_> = self.macros.steps.iter().map(|s| s.request.clone()).collect();
let session = crate::http::session::Session::new(self.store.entries().to_vec(), macro_requests);
match std::fs::File::create(path) {
Ok(file) => {
let writer = std::io::BufWriter::new(file);
match serde_json::to_writer_pretty(writer, &session) {
Ok(()) => {
self.set_status(format!("Saved to {}", path.display()));
}
Err(e) => {
self.set_status(format!("Save failed: {}", e));
}
}
}
Err(e) => {
self.set_status(format!("Save failed: {}", e));
}
let macro_requests: Vec<_> = self
.macros
.steps
.iter()
.map(|s| s.request.clone())
.collect();
let session =
crate::http::session::Session::new(self.store.entries().to_vec(), macro_requests);
let result = crate::fs_security::write_private_with(path, |file| {
use std::io::Write;
let mut writer = std::io::BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &session).map_err(std::io::Error::other)?;
writer.flush()
});
match result {
Ok(()) => self.set_status(format!("Saved to {}", path.display())),
Err(e) => self.set_status(format!("Save failed: {}", e)),
}
}

Expand All @@ -100,7 +94,11 @@ impl App {
let name = crate::rules::persist::auto_save_name();
match crate::rules::persist::save(&rules, &name) {
Ok(path) => {
self.set_status(format!("Exported {} rules to {}", rules.len(), path.display()));
self.set_status(format!(
"Exported {} rules to {}",
rules.len(),
path.display()
));
}
Err(e) => {
self.set_status(format!("Export failed: {}", e));
Expand All @@ -113,11 +111,7 @@ impl App {
Ok(session) => {
self.store.load_entries(session.entries);
if let Some(saved) = session.macros {
self.macros.steps = saved
.steps
.into_iter()
.map(SequenceStep::new)
.collect();
self.macros.steps = saved.steps.into_iter().map(SequenceStep::new).collect();
self.macros.selected = 0;
self.macros.running = false;
self.macros.current_step = 0;
Expand Down
143 changes: 75 additions & 68 deletions src/app/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,83 +4,90 @@ use crate::tui::tabs::Tab;

use super::{App, EditorTarget};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputContext {
Help,
QuitConfirmation,
SaveDialog,
CertificateInfo,
InterceptEditor,
RepeaterEditor,
HistoryFilter,
ToolsEditor,
BindAddressEditor,
ScopeEditor,
RuleEditor,
RuleImport,
Tab,
}

impl App {
pub fn handle_event(&mut self, event: Event) {
if let Event::Key(key) = event {
if key.kind != crossterm::event::KeyEventKind::Press {
return;
}

if self.show_help {
self.show_help = false;
return;
}

if self.show_quit_confirm {
self.handle_quit_confirm_key(key);
return;
}

if self.show_save_dialog {
self.handle_save_dialog_key(key);
return;
}

if self.show_cert_info {
self.handle_cert_overlay_key(key);
return;
}

if self.intercept_ui.editing {
self.handle_editor_key(key, EditorTarget::Intercept);
return;
}

if self.repeater.editing {
self.handle_editor_key(key, EditorTarget::Repeater);
return;
}

if self.history.filtering {
self.handle_filter_key(key);
return;
}

if self.tools.editing {
self.handle_tools_editor_key(key);
return;
}

if self.editing_bind_addr {
self.handle_bind_addr_editor_key(key);
return;
}

if self.editing_scope {
self.handle_scope_editor_key(key);
return;
}

if self.rules_ui.editing_field.is_some() {
self.handle_rules_editor_key(key);
return;
}

if self.rules_ui.importing {
self.handle_rules_import_editor_key(key);
return;
match self.input_context() {
InputContext::Help => self.show_help = false,
InputContext::QuitConfirmation => self.handle_quit_confirm_key(key),
InputContext::SaveDialog => self.handle_save_dialog_key(key),
InputContext::CertificateInfo => self.handle_cert_overlay_key(key),
InputContext::InterceptEditor => {
self.handle_editor_key(key, EditorTarget::Intercept);
}
InputContext::RepeaterEditor => {
self.handle_editor_key(key, EditorTarget::Repeater);
}
InputContext::HistoryFilter => self.handle_filter_key(key),
InputContext::ToolsEditor => self.handle_tools_editor_key(key),
InputContext::BindAddressEditor => self.handle_bind_addr_editor_key(key),
InputContext::ScopeEditor => self.handle_scope_editor_key(key),
InputContext::RuleEditor => self.handle_rules_editor_key(key),
InputContext::RuleImport => self.handle_rules_import_editor_key(key),
InputContext::Tab => {
if self.handle_global_key(key) {
return;
}
match self.active_tab {
Tab::History => self.handle_history_key(key),
Tab::Proxy => self.handle_proxy_key(key),
Tab::Repeater => self.handle_repeater_key(key),
Tab::Rules => self.handle_rules_key(key),
Tab::Tools => self.handle_tools_key(key),
}
}
}
}
}

if self.handle_global_key(key) {
return;
}
match self.active_tab {
Tab::History => self.handle_history_key(key),
Tab::Proxy => self.handle_proxy_key(key),
Tab::Repeater => self.handle_repeater_key(key),
Tab::Rules => self.handle_rules_key(key),
Tab::Tools => self.handle_tools_key(key),
}
fn input_context(&self) -> InputContext {
if self.show_help {
InputContext::Help
} else if self.show_quit_confirm {
InputContext::QuitConfirmation
} else if self.show_save_dialog {
InputContext::SaveDialog
} else if self.show_cert_info {
InputContext::CertificateInfo
} else if self.intercept_ui.editing {
InputContext::InterceptEditor
} else if self.repeater.editing {
InputContext::RepeaterEditor
} else if self.history.filtering {
InputContext::HistoryFilter
} else if self.tools.editing {
InputContext::ToolsEditor
} else if self.editing_bind_addr {
InputContext::BindAddressEditor
} else if self.editing_scope {
InputContext::ScopeEditor
} else if self.rules_ui.editing_field.is_some() {
InputContext::RuleEditor
} else if self.rules_ui.importing {
InputContext::RuleImport
} else {
InputContext::Tab
}
}

Expand Down
Loading
Loading