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
81 changes: 76 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,86 @@ To use the proxy, configure your browser to use it:
## CLI Options

```text
devrelay [-h] [--host HOST] [--port PORT] [--confdir CONFDIR]
devrelay [-h] [--host HOST] [--port PORT] [--certdir CERTDIR] [--disable-addon ADDON]

Options:
-h, --help Show help message
--host HOST Host address to bind to (default: 127.0.0.1)
--port PORT Port to listen on (default: 8080)
--confdir CONFDIR Certificate directory (default: ~/.mitmproxy)
-h, --help Show help message
--host HOST Host address to bind to (default: 127.0.0.1)
--port PORT Port to listen on (default: 8080)
--certdir CERTDIR Certificate directory (default: ~/.mitmproxy)
--disable-addon ADDON Disable specific addon(s) (can be used multiple times)
```

### Disabling Addons

You can selectively disable specific addons using the `--disable-addon` option.
This is useful when you only need to remove specific security headers.

**Available addons:**

- `CSP` - Content-Security-Policy remover
- `COEP` - Cross-Origin-Embedder-Policy remover
- `COOP` - Cross-Origin-Opener-Policy remover
- `CORP` - Cross-Origin-Resource-Policy inserter
- `CORSInserter` - CORS headers inserter for webhooks
- `CORSPreflight` - CORS preflight handler for webhooks

**Examples:**

Disable CSP and COEP addons:

```bash
devrelay --disable-addon CSP --disable-addon COEP
```

Disable multiple addons with comma-separated values:

```bash
devrelay --disable-addon CSP,COEP,COOP
```

Combine addon disabling with other options:

```bash
devrelay --host 0.0.0.0 --port 9090 --disable-addon CSP
```

You can also use full addon class names:

```bash
devrelay --disable-addon CSPRemoverAddon --disable-addon COEPRemoverAddon
```

Addon names are case-insensitive:

```bash
devrelay --disable-addon csp --disable-addon COEP
```

## Configuration File

DevRelay supports configuration via a YAML file located at `~/.mitmproxy/devrelay.yaml`.
The file is automatically created with default values on first run.

**Example configuration:**

```yaml
host: 127.0.0.1
port: 8080
certdir: /home/user/.mitmproxy
disabled_addons:
- CSP
- COEP
```

Configuration precedence (highest to lowest):

1. Command-line arguments
2. YAML configuration file
3. Default values

This means CLI arguments will override values in the YAML file.

## Development

### Available Make Targets
Expand Down
79 changes: 79 additions & 0 deletions devrelay/addons.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,86 @@
"""DevRelay proxy addons for security header manipulation"""

import difflib
from mitmproxy import http

# Maximum length for addon names to be considered "short" for user-friendly suggestions
SHORT_NAME_MAX_LENGTH = 15

# Addon name mapping for user-friendly names
# Maps both short names and full class names to canonical class names
ADDON_NAME_MAP = {
# Short names (case will be normalized to uppercase for lookup)
"CSP": "CSPRemoverAddon",
"COEP": "COEPRemoverAddon",
"COOP": "COOPRemoverAddon",
"CORP": "CORPInserterAddon",
"CORSINSERTER": "CORSInserterForWebhooksAddon",
"CORSPREFLIGHT": "CORSPreflightForWebhooksAddon",
# Full class names (for completeness)
"CSPREMOVERADDON": "CSPRemoverAddon",
"COEPREMOVERADDON": "COEPRemoverAddon",
"COOPREMOVERADDON": "COOPRemoverAddon",
"CORPINSERTERADDON": "CORPInserterAddon",
"CORSINSERTERFORWEBHOOKSADDON": "CORSInserterForWebhooksAddon",
"CORSPREFLIGHTFORWEBHOOKSADDON": "CORSPreflightForWebhooksAddon",
}


def validate_addon_names(addon_names: list[str]) -> list[str]:
"""
Validate and normalize addon names to canonical class names.

Accepts both short names (e.g., "CSP", "COEP") and full class names
(e.g., "CSPRemoverAddon"). Case-insensitive matching is supported.

Args:
addon_names: List of addon names to validate

Returns:
List of canonical addon class names

Raises:
ValueError: If any addon name is invalid, with a suggestion for the first invalid name
"""
validated = []

for name in addon_names:
# Normalize to uppercase for case-insensitive lookup
normalized = name.upper()

# Check if it's a valid addon name
if normalized in ADDON_NAME_MAP:
canonical_name = ADDON_NAME_MAP[normalized]
validated.append(canonical_name)
else:
# Invalid name - provide a suggestion using fuzzy matching
# Get all possible input names (both short and full)
all_possible_names = list(ADDON_NAME_MAP.keys())

# Find close matches (case-insensitive)
close_matches = difflib.get_close_matches(normalized, all_possible_names, n=1, cutoff=0.6)

if close_matches:
# Suggest the canonical class name for the close match
suggested_canonical = ADDON_NAME_MAP[close_matches[0]]
# Find a user-friendly version (prefer short names)
user_friendly = None
for key, value in ADDON_NAME_MAP.items():
if value == suggested_canonical and len(key) <= SHORT_NAME_MAX_LENGTH:
user_friendly = key
break
if user_friendly is None:
user_friendly = suggested_canonical

raise ValueError(f"Unknown addon '{name}'. Did you mean '{user_friendly}'?")
else:
# No close match - list all valid short names
# Derive short names from ADDON_NAME_MAP (names <= SHORT_NAME_MAX_LENGTH chars)
valid_short_names = sorted(set(k for k in ADDON_NAME_MAP.keys() if len(k) <= SHORT_NAME_MAX_LENGTH))
raise ValueError(f"Unknown addon '{name}'. Valid addons: {', '.join(valid_short_names)}")

return validated


class CSPRemoverAddon:
"""
Expand Down
13 changes: 9 additions & 4 deletions devrelay/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,31 @@ def __init__(self, config_path: Path | None = None) -> None:
"""
self.config_loader = ConfigLoader(config_path=config_path)

def display_startup_info(self, host: str, port: int, certdir: Path) -> None:
def display_startup_info(self, host: str, port: int, certdir: Path, disabled_addons: list[str]) -> None:
"""
Display startup information to the user.

Args:
host: Host address being used
port: Port number being used
certdir: Certificate directory path
disabled_addons: List of disabled addon names
"""
print(f"Starting DevRelay proxy on {host}:{port}")
print(f"Certificate directory: {certdir}")
if disabled_addons:
print(f"Disabled addons: {', '.join(disabled_addons)}")
print("\nPress Ctrl+C to stop the proxy\n")

def run_server(self, host: str, port: int, certdir: Path) -> int:
def run_server(self, host: str, port: int, certdir: Path, disabled_addons: list[str]) -> int:
"""
Start and run the proxy server.

Args:
host: Host address to bind to
port: Port number to listen on
certdir: Certificate directory
disabled_addons: List of addon class names to disable

Returns:
Exit code (0 for success, 1 for error)
Expand All @@ -50,6 +54,7 @@ def run_server(self, host: str, port: int, certdir: Path) -> int:
host=host,
port=port,
certdir=certdir,
disabled_addons=disabled_addons,
)
server.run()
except KeyboardInterrupt:
Expand All @@ -73,8 +78,8 @@ def execute(self, args: list[str] | None = None) -> int:
"""
try:
config = self.config_loader.get_config(args)
self.display_startup_info(config.host, config.port, config.certdir)
return self.run_server(config.host, config.port, config.certdir)
self.display_startup_info(config.host, config.port, config.certdir, config.disabled_addons)
return self.run_server(config.host, config.port, config.certdir, config.disabled_addons)
except ValueError as e:
print(f"Configuration error: {e}", file=sys.stderr)
return 1
Expand Down
84 changes: 77 additions & 7 deletions devrelay/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from ruamel.yaml import YAML

from devrelay.addons import validate_addon_names


@dataclass
class Parameter:
Expand Down Expand Up @@ -53,6 +55,12 @@ def __init__(self, config_path: Path | None = None) -> None:
default=Path.home() / ".mitmproxy",
help="Certificate directory",
),
Parameter(
name="disabled_addons",
type=list,
default=[],
help="Comma-separated list of addons to disable (e.g., CSP,COEP)",
),
]

self.parser = self._build_parser()
Expand All @@ -70,15 +78,63 @@ def _build_parser(self) -> argparse.ArgumentParser:
)

for param in self.parameters:
parser.add_argument(
f"--{param.name}",
type=param.type,
default=param.default,
help=param.help,
)
# Special handling for list types (disabled_addons)
if param.type == list:
# Use --disable-addon (singular) for better UX
arg_name = "--disable-addon"
# action='append' allows repeated usage: --disable-addon CSP --disable-addon COEP
parser.add_argument(
arg_name,
action="append",
default=None, # Use None to detect if user provided values
help=param.help,
dest=param.name, # Store in disabled_addons
Comment thread
bcdonadio marked this conversation as resolved.
)
else:
parser.add_argument(
f"--{param.name}",
type=param.type,
default=param.default,
help=param.help,
)

return parser

def _parse_addon_list(self, raw_value: Any) -> list[str]:
"""
Parse addon list from CLI or YAML format.

Handles both comma-separated strings and lists.
CLI format: --disable-addon CSP,COEP or --disable-addon CSP --disable-addon COEP
YAML format: disabled_addons: [CSP, COEP] or disabled_addons: CSP,COEP

Args:
raw_value: Raw value from CLI or YAML (None, str, or list)

Returns:
Parsed list of addon names
"""
if raw_value is None:
return []

# If it's already a list (from action='append' or YAML)
if isinstance(raw_value, list):
# Flatten and split comma-separated values
result = []
for item in raw_value:
if isinstance(item, str):
# Split by comma and strip whitespace
result.extend([x.strip() for x in item.split(",") if x.strip()])
else:
result.append(item)
return result

# If it's a string (from YAML), split by comma
if isinstance(raw_value, str):
return [x.strip() for x in raw_value.split(",") if x.strip()]

return []

def _load_yaml(self) -> dict[str, Any]:
"""
Load configuration from YAML file.
Expand Down Expand Up @@ -127,6 +183,9 @@ def _validate_value(self, param: Parameter, value: Any) -> Any:
ValueError: If value is invalid
"""
if value is None:
# For list types, return empty list instead of None
if param.type == list:
return []
return None

# Type conversion
Expand All @@ -147,6 +206,13 @@ def _validate_value(self, param: Parameter, value: Any) -> Any:
return result
elif param.type == str:
return str(value)
elif param.type == list:
# Handle list types (currently only disabled_addons)
parsed_list = self._parse_addon_list(value)
# Validate addon names if this is the disabled_addons parameter
if param.name == "disabled_addons":
return validate_addon_names(parsed_list)
return parsed_list
else: # pragma: no cover
return value
except (ValueError, TypeError):
Expand Down Expand Up @@ -203,7 +269,11 @@ def get_config(self, args: list[str] | None = None) -> argparse.Namespace:
cli_value = getattr(cli_args, param.name)

# Check if CLI value is the default (meaning user didn't provide it)
is_cli_default = cli_value == param.default
# For list types, CLI default is None, so check for None explicitly
if param.type == list:
is_cli_default = cli_value is None
else:
is_cli_default = cli_value == param.default

if is_cli_default and param.name in yaml_config:
# Use YAML value if CLI wasn't provided
Expand Down
Loading
Loading