SECURITY.md
Rama main · réplica de lectura del repositorio original.
<p align="center"><a href="SECURITY.zh-CN.md">简体中文</a> · <b>English</b></p>
# Security Policy
ResumeForge is a single-user tool that runs locally for individuals. It handles sensitive material — resumes, contact details, photos, experience summaries and LLM API keys — so read the boundaries below before you rely on it.
## Supported versions
| Version | Security updates |
| ------- | ---------------- |
| `2.x` (current line: `main` plus the latest release `2.0.0`, 2026-11-04) | Supported |
| `1.0.x` (`1.0.2` is the final release) | No longer updated; see the note on opening 1.0.x data below |
| Earlier commits or locally modified builds | Not guaranteed |
Note on 1.0.x data: on first start, 2.0 archives the whole 1.0.x database into a local `legacy-archive/<timestamp>/` directory and then creates an empty database. When you need the old content, open that archive with 1.0.2. 2.0 does **not** import 1.0.x backup packages — the database inside such a package carries no `app_schema_epoch`, so it is rejected with a hint to open it with 1.0.2.
## Reporting a vulnerability
Prefer the private security advisory feature of the code hosting platform (**Security → Advisories → Report a vulnerability** in the GitHub repository). If that entry point is unavailable, report through the private contact details published in the maintainer's profile.
Do not include any of the following in public issues, screenshots or logs:
- API keys, access tokens or complete request headers;
- real resumes, photos, contact details or the body of experience summaries;
- local databases, `.env` files or exports that can reconstruct personal information.
Please include the affected version, reproduction conditions, blast radius and the minimal verification you performed. Maintainers will acknowledge receipt as soon as possible; avoid disclosing directly exploitable details until a fix is public.
## Local security boundaries
- **API keys at rest on Windows** are encrypted with the system DPAPI and stored in the local SQLite database (the ciphertext is bound to the current user and machine). Non-Windows platforms keep them in plaintext, in exchange for a zero-dependency, single-user, zero-configuration experience. Ordinary settings reads and the settings-record endpoints always return a redacted reference. `POST /api/settings/llm/api-key/reveal` exists for clients that call the API from the same machine and is open only to a direct loopback connection; its response is marked no-cache. **The shipped interface does not expose that entry point** — no production frontend code calls it, and the settings page only shows a redacted value — so in practice it serves local clients that talk to the API directly. This restriction is no substitute for authentication: do not share `backend/data/resume_forge.db`, and protect the operating-system account and the disk.
- **Dataset backups include LLM API keys by default** (corrected against the actual implementation on 2026-10-24): the `include_api_keys` parameter of `GET /api/settings/datasets/export` / `export-all` defaults to `true`, and the frontend export call is checked by default too — the point is that "move to another machine and import the whole package" should not require re-entering keys. Keys are written in their **on-disk form** (DPAPI ciphertext on Windows, bound to the current user and machine, so undecryptable elsewhere; plaintext on non-Windows); they are never decrypted into the package. **Packages that contain keys use higher backup format numbers**: `format 3` = a single dataset with keys, `format 5` = a collection package that additionally carries the global model configuration in `global_llm.json`, `format 6` = 5 plus font files (currently `BACKUP_FORMAT_VERSION = 6`). Older versions explicitly reject a higher-format package, and the importer requires the manifest to declare the key state. **Keys are stripped only when the user explicitly unchecks "include API key"** (the table is cleared and the file rebuilt with `VACUUM`, so no free pages retain data). **Treat any backup package as a file as sensitive as the database itself**, and confirm whether it contains keys before handing it to anyone.
- When generating a resume or job-specific suggestions, the relevant job information, experience/project/skill entries and the selected summary-file excerpts are sent to the model provider the user configured; identity fields such as photo, name and contact details never enter the model context. Interpreting a job posting sends only the posting, never personal data. Check the provider's data policy before use.
- Resume photos stay local and never enter the model context; exported HTML or print files may still contain the photo and other personal information.
- The AI job assistant sends only the current question and a limited history of completed sessions by default. Related content joins the context only when the user explicitly links a job, links a resume or chooses "use my data". "Use my data" strips photo, name and contact details; a linked resume only strips the photo, so a name and contact details inside the body may still be sent. The assistant can read and write local data through tool calls. Writes (adding/editing a job, updating basic profile fields) happen only on explicit user request, and there are **no delete tools at all**; model configuration and dataset management under settings keep their loopback enforcement and are deliberately not exposed as tools, so the model cannot indirectly bypass that boundary.
- Assistant sessions, messages, attachments and web-source summaries are stored in the local SQLite database. Deleting a session deletes its message history; database backups may still hold older copies. Images a user uploads to the assistant differ from profile photos: images in the current message are sent to the selected model, and only multimodal models compatible with OpenAI's `image_url` input handle them correctly.
- **PDF/DOCX attachments are never sent out as original files**: the backend extracts text locally (pypdf for PDF, a whitelisted member read for DOCX) and only the extracted text enters the model context (same for job and material recognition documents), so document recognition needs no multimodal model and the file itself never reaches the provider. BMP/TIFF images are likewise converted to PNG/JPEG locally before sending. When a scan yields no text, the app says so and suggests taking a screenshot instead of silently leaving it blank.
- Assistant attachments accept only UTF-8 TXT/Markdown/JSON/CSV, PDF/DOCX and PNG/JPEG/WebP/GIF/BMP/TIFF images, at most 4 files, each up to 2 MB and 5 MB in total; the server validates the file name, MIME type, header signature and base64. Parsing these untrusted files has further caps: at most 30 PDF pages, a ceiling on total extracted text, a pixel cap when decoding images, and DOCX reads only `word/document.xml` instead of unzipping the package. Format and size limits do not replace content hygiene — remove unrelated personal data and keys before uploading.
- The HTTP client's default INFO request summary is disabled, so assistant search queries never land in logs as full URLs; business logs keep only the host, status code, counts and request ID needed for troubleshooting.
- **Web search** gets the assistant a search tool once enabled: it decides what to look up and may retry with different keywords (at most 3 searches per answer, including the automatic pre-search that happens when web access is on). There are **four optional sources**: `bing`, `duckduckgo`, `searxng` and `deepseek`. **Only `bing` and `duckduckgo` are enabled by default** (Bing goes to `https://cn.bing.com/search`, falling back to `https://www.bing.com/search` when that domain is unavailable); `searxng` requires the user to supply an instance URL. **`deepseek` is disabled by default: it needs a separately configured DeepSeek API key and an explicit opt-in.** Once DeepSeek is enabled, the query string goes to `https://api.deepseek.com/v1/tools/search`, and when "pages to fetch" is greater than 0 the **target page URL is sent to `api.deepseek.com/v1/tools/fetch` and fetched by that service's own servers** (locally only the scheme/credential check runs first; the local hop-by-hop SSRF validation no longer applies on that path). On account-level unavailability (401/402/403/429) the whole fetch step falls back to local fetching. The remaining boundaries are unchanged: at most 3 web searches per answer, `fetch_pages` defaults to 0 with a maximum of 3, only http/https is allowed, non-standard ports and private/loopback/link-local addresses are rejected (IPv4/IPv6 and every resolved address), redirects are validated hop by hop, and response body size and timeouts are capped. **This defends against "the result points at the intranet" style direct SSRF, but it cannot claim complete protection against DNS rebinding** — a time window remains between the resolution check and the actual connection, which is why page fetching is off by default; enable it only on networks you trust. Results are deduplicated by host and path, and employer sites and job pages rank ahead of third-party boards; snippets may be stale or third-party and are not proof of official status or that a job is still open. Turning the switch off removes the tool entirely.
- **Auto-collect, auto-apply and web-form filling actively visit job sites** (the only two **job-site** outbound paths in the project; assistant web search is a separate pipeline), but only for the single run the user explicitly triggers — nothing runs in the background. Each pipeline goes through its own dedicated browser launched by the app, with a separate `user-data-dir`, isolated from your everyday browser. **The app does not collect, type or store job-site account credentials, and does not read or save login cookies**: the first login is a QR scan you perform yourself in that dedicated browser window, and the credentials exist only inside that browser's data directory. The web-form side **fills but never submits** (no submit click, no form-submit script). Apply and collect logs redact credentials such as passwords, cookies and request headers, keeping only the steps, states and error categories needed for troubleshooting; verification codes and security challenges are never bypassed — they are recorded as failures and handed back to the user. Still review logs and exports yourself before sharing them.
- Default deployments must listen on the loopback address only. CORS is not authentication; never expose the backend port to a LAN or the internet.
- "Generation output location" is a local directory the user explicitly picks, not a server-side upload directory; only the backend's native folder picker can set it: the directory probe and the persistence both happen on the picker's return value, and the HTTP settings endpoint only accepts a clear marker, never a client-supplied path. Selecting and clearing are allowed only for loopback clients; the Origin / Fetch Metadata defenses reject cross-site write requests. Both GET and POST of resume export require a direct loopback connection and a non-cross-site request; otherwise the request returns 403 before the resume is queried, the directory probe runs or any artifact is written. Local frontends and browser-less local CLIs can still save according to settings.
- **Pre-send preview and usage ledger**: before calling a model capability such as generation or suggestions, the app previews the target service, the categories of data being sent, the attachment types, the excluded fields and whether page bodies will be fetched; the preview body is rendered in memory only and is **never written to localStorage, logs or the database**. The usage ledger aggregates tokens, latency, finish reason and retry count per task/model/dataset, marks missing token counts as "unknown" and estimates cost only when the user configured unit prices; **the ledger never stores prompt bodies, API keys, passwords, complete personal profiles or raw attachments**. Diagnostic events follow the same privacy rules (categories, counts, latency and error categories only).
- Install dependencies from the lockfiles/version manifests in the repository, and review Dependabot and CI dependency-audit results.
## Windows desktop build (since 1.0.0)
- **Data lives in the user directory**: `%APPDATA%\ResumeForge\data` holds the main database, attachment copies, backups and the login-state directories of the dedicated apply and web-form browsers (the `RF_DATA_DIR` environment variable overrides it; console and portable behavior is unchanged). Upgrades, uninstalls and reinstalls never touch that directory; do not put it on a cloud-sync drive or share it with anyone.
- **The in-app updater verifies before installing**: after the download it checks the SHA-256 the publisher ships with the asset (malformed values — e.g. legacy sidecars with a coreutils `*` prefix — are rejected outright), together with the stable version number, the target platform and the origin of the release asset; failing any of them stops the install, download I/O errors never hang, and a failed child-process launch reaches an explicit terminal state.
- **Every release asset can be verified individually**: each one ships with a matching `.sha256` file. When the project has no code-signing certificate configured, the installers are **unsigned**, so Windows SmartScreen shows an "unknown publisher" prompt — judge by the download source and the `.sha256`, and do not read the prompt itself as evidence that the file was tampered with.
- **User data gets a full snapshot before program files are overwritten**: `update.cmd` (which calls `scripts/Update-ResumeForge.ps1`) runs `backend/scripts/create_user_data_snapshot.py` before the overwrite. The script reuses the "export all datasets" pipeline and verifies that the archive is readable; if the script is missing, the updater falls back to copying `*.db` plus a few JSON pointer files. Snapshots land in `backend/data/pre-update/<timestamp>/`; ordinary snapshots keep only the latest 3, while names containing `pre-major` (cross-major upgrades) are **never** cleaned up automatically. **Snapshot packages include LLM API keys by default** — the same rule as the backup packages above — so treat them as equally sensitive as the data directory. Installer-based upgrades leave the data directory alone, because it sits outside the install path.
- **Data rescue when upgrading from the 0.18.0 installer**: the old installer wrote data inside the install directory, and an NSIS in-place upgrade runs the old uninstaller first. Since 1.0.0 the installer copies old data file by file into a temporary directory and verifies byte counts **before** touching the old version — detecting that the app is still running, or a failed verification, **aborts the install** and leaves the original data in place; after installation the app's data-migration flow takes over (back up, ask for confirmation, then move into `%APPDATA%\ResumeForge\data`). An existing database at the target location is never overwritten.
- **Delivery archive fingerprints**: every application record keeps a delivery snapshot, so a resume can be re-exported even after the original was deleted; the replayed artifact may differ byte-wise from the original (HTML re-renders get a fresh CSP nonce), which the UI states plainly alongside the original artifact's SHA-256 fingerprint. Confirm an archive contains no personal information you do not want to disclose before handing it out.
- **The print bridge only accepts in-app requests**: the desktop shell's print IPC accepts only the main window's top-level frame and validates the channel and the payload shape (≤5 MB of UTF-8 bytes); loading fonts/images, readiness and printing each have budgets, and both errors and timeouts destroy the hidden window. `nodeIntegration: false` / `contextIsolation: true` / `sandbox: true` are unchanged, the print window refuses navigation and new windows, and print input comes only from app-generated HTML — arbitrary URLs are not accepted, so resume web content cannot reach system capabilities through the print bridge. The CI `desktop-shell` job runs a real Electron smoke test: real IPC channel + hidden window + Chromium `printToPDF` producing a PDF, plus checks that non-main-window senders, invalid or oversized payloads and over-budget paths are all rejected and shut down quickly; environments that need a graphical session are not claimed as verified locally.
## Recommendations for users
- Use least-privilege, individually revocable API keys and rotate them regularly.
- Back up the database regularly; `backend/scripts/backup_database.py` creates a consistent SQLite backup. Keep backup files encrypted and in a controlled location.
- Remove personal information before sharing logs or exported files.
- Delete assistant sessions you no longer need, and check the configured model provider and its retention policy before sending attachments, jobs, resumes or profile material.
- Before using auto-collect or auto-apply, confirm the target job site allows automation; the login credentials stay in your own apply browser, so log out there and clear its data directory when you stop using it.
- Get the project from a trusted source, and check dependency updates and release notes.