-
Notifications
You must be signed in to change notification settings - Fork 13
Security
AutoApply is a desktop application that runs a local Flask server bound to 127.0.0.1. It does not accept connections from the network. Security measures protect against local threats, credential leakage, and common web vulnerabilities.
All API requests require a Bearer token:
Authorization: Bearer <token>
-
Generation: A cryptographically random token is generated on first run using
secrets.token_urlsafe(32). -
Storage: Saved to
~/.autoapply/.api_tokenwith file permissions restricted to the current user. -
Injection: The Electron frontend receives the token via
window.__apiToken, injected intoindex.htmlby Jinja2 at template render time. -
Validation: Checked in
@app.before_requestfor all/api/*routes. -
Exclusions: The health endpoint (
GET /api/health) and static file serving are excluded from auth checks.
Setting AUTOAPPLY_DEV=1 disables authentication checks. This is used in development and testing. Never set this in production.
LLM API keys (Anthropic, OpenAI, Google, DeepSeek) are stored securely using the OS keyring:
| Platform | Keyring Backend |
|---|---|
| Windows | Windows Credential Locker (via keyring library) |
| macOS | macOS Keychain (via keyring library) |
| Linux | SecretService API -- GNOME Keyring or KWallet (via keyring library) |
- User enters API key in the Settings UI or setup wizard.
-
PUT /api/configreceives the key. - If keyring is available, the key is stored in the keyring under service
autoapplywith usernamellm_api_key. - The
config.jsonfile stores only a placeholder:"api_key": "***". - If keyring is unavailable, the key is stored in plaintext in
config.jsonas a fallback.
On startup, if a plaintext API key is found in config.json and the keyring is available:
- The key is written to the keyring.
- The plaintext key in
config.jsonis replaced with"***". - A log message records the migration.
All responses include security headers set in the @app.after_request handler:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
X-Frame-Options |
DENY |
Prevents clickjacking via iframes |
X-XSS-Protection |
1; mode=block |
Enables browser XSS filter |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer information leakage |
Cache-Control |
no-store, no-cache, must-revalidate |
Prevents caching of API responses |
Cross-Origin Resource Sharing is locked to localhost origins only:
CORS(app, origins=["http://127.0.0.1:*", "http://localhost:*"])This prevents any external website from making API requests to the local AutoApply server, even if the Bearer token were leaked.
Requests are rate-limited using a token bucket algorithm per client IP address:
| Bucket | Capacity | Refill Rate | Applies To |
|---|---|---|---|
| Bot | 10/min | 1 token every 6s | POST /api/bot/* |
| Write | 30/min | 1 token every 2s |
PUT, POST, PATCH, DELETE operations |
| Read | 60/min | 1 token every 1s |
GET operations |
When a bucket is exhausted, the server returns:
HTTP/1.1 429 Too Many Requests
Retry-After: 6
Content-Type: application/json
{"error": "Rate limit exceeded. Try again in 6 seconds."}
The Retry-After header indicates the number of seconds until the next token is available.
All configuration input is validated through Pydantic v2 models defined in config/settings.py. Invalid data returns 422 Unprocessable Entity with detailed error messages.
The PATCH /api/applications/:id endpoint validates the status field against an explicit allowlist:
ALLOWED_STATUSES = {"applied", "reviewing", "rejected", "interview", "offer", "closed"}Any status value not in the allowlist is rejected with 400 Bad Request.
Flask is configured with a 16 MB maximum request size:
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024Requests exceeding this limit receive 413 Request Entity Too Large.
Endpoints that accept filenames (experience file upload/download, resume/description serving) use _is_safe_path() to validate paths:
def _is_safe_path(base_dir, user_path):
"""Ensure the resolved path is within the allowed base directory."""
resolved = os.path.realpath(os.path.join(base_dir, user_path))
return resolved.startswith(os.path.realpath(base_dir))This prevents directory traversal attacks like ../../etc/passwd. Filenames are additionally validated against an allowlist regex:
SAFE_FILENAME = re.compile(r'^[a-zA-Z0-9_\-][a-zA-Z0-9_\-. ]{0,254}$')The POST /api/login/open endpoint validates the URL parameter:
-
Parses the URL using
urllib.parse.urlparse. -
Validates scheme: Must be
httporhttps. -
Checks domain against an allowlist of known platforms:
-
linkedin.com,www.linkedin.com -
indeed.com,www.indeed.com -
greenhouse.io,boards.greenhouse.io -
lever.co,jobs.lever.co -
myworkdayjobs.com,myworkday.com -
ashbyhq.com,jobs.ashbyhq.com
-
Any URL not matching an allowed domain is rejected with 400 Bad Request.
In production (when AUTOAPPLY_DEV is not set), error responses never expose stack traces or internal details:
@app.errorhandler(500)
def internal_error(e):
return jsonify({"error": "Internal server error"}), 500Global error handlers are registered for all common HTTP error codes (400, 401, 403, 404, 405, 413, 422, 429, 500).
Error messages are generic and user-facing. Internal details are logged server-side only:
# Bad: exposes internals
{"error": "sqlite3.OperationalError: database is locked"}
# Good: generic message
{"error": "Failed to save application. Please try again."}The CI security job runs pip-audit to check all Python dependencies against the OSV vulnerability database:
pip-audit --strictIf any known vulnerabilities are found, the CI job fails, blocking the PR from merging.
GitHub Dependabot is configured (.github/dependabot.yml) to automatically:
- Check for outdated Python packages (weekly).
- Check for outdated npm packages (weekly).
- Open pull requests with version bumps.
- Flag packages with known security advisories.
All Python dependencies are pinned in pyproject.toml with minimum versions to ensure reproducible builds and prevent supply chain attacks from unexpected upgrades:
[project]
dependencies = [
"flask>=3.0.0",
"flask-socketio>=5.3.0",
"pydantic>=2.0.0",
# ...
]The Flask SECRET_KEY (used for session signing) is:
-
Generated using
secrets.token_hex(32)on first run. -
Persisted to
~/.autoapply/.flask_secretfor consistency across restarts. - File permissions restricted to the current user.
- Never logged or exposed in API responses.
The Flask server binds exclusively to the loopback interface:
socketio.run(app, host='127.0.0.1', port=port)This ensures:
- The server is not accessible from other machines on the network.
- Only local processes (the Electron shell, local development tools) can connect.
- No firewall rules are needed.
| Control | Status | Implementation |
|---|---|---|
| API authentication | Implemented | Bearer token, auto-generated |
| API key encryption at rest | Implemented | OS keyring with plaintext fallback |
| Security headers | Implemented | 5 headers on all responses |
| CORS restriction | Implemented | Localhost-only origins |
| Rate limiting | Implemented | Token bucket, 3 tiers |
| Input validation | Implemented | Pydantic, allowlists, size limits |
| Path traversal protection | Implemented |
_is_safe_path() + filename regex |
| URL validation | Implemented | Domain allowlist on login endpoint |
| Error handling | Implemented | No stack traces, generic messages |
| Dependency scanning | Implemented | pip-audit in CI, Dependabot |
| Secret key management | Implemented | Random generation, file persistence |
| Network binding | Implemented | 127.0.0.1 only |
| Code signing | Not yet | Tracked in Roadmap |
AutoApply Wiki
User Guide
Technical
Build & Deploy
Project