Coverage for src/local_deep_research/settings/env_definitions/security.py: 100%
4 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Security environment settings.
4These settings control security-related behavior like SSRF validation
5and CORS origin restrictions.
6"""
8import os
9from ..env_settings import BooleanSetting, StringSetting
12# External environment variables (set by pytest, CI systems)
13# These are read directly since we don't control them
14PYTEST_CURRENT_TEST = os.environ.get("PYTEST_CURRENT_TEST")
17# LDR Security settings (our application's security configuration)
18SECURITY_SETTINGS = [
19 StringSetting(
20 key="security.cors.allowed_origins",
21 description=(
22 "Allowed CORS origins for API routes (comma-separated). "
23 "Use '*' for all origins, empty for same-origin only. "
24 "Example: 'https://example.com,https://app.example.com'"
25 ),
26 default=None,
27 ),
28 StringSetting(
29 key="security.websocket.allowed_origins",
30 description=(
31 "Allowed origins for WebSocket/Socket.IO connections (comma-separated). "
32 "Unset or empty means same-origin only (default); use '*' to allow all origins. "
33 "Example: 'https://example.com,https://app.example.com'"
34 ),
35 default=None,
36 ),
37 BooleanSetting(
38 key="notifications.allow_private_ips",
39 description=(
40 "Allow notification webhooks to target private/local IP addresses. "
41 "Environment-only to prevent SSRF bypass via the user-writable settings API. "
42 "Only enable this if your notification endpoints are on a trusted local network."
43 ),
44 default=False,
45 ),
46 BooleanSetting(
47 key="security.allow_nat64",
48 description=(
49 "Allow outbound traffic to NAT64 prefixes (64:ff9b::/96 RFC 6052 "
50 "well-known and 64:ff9b:1::/48 RFC 8215 local-use). Disabled by "
51 "default to close the IPv6-wrapped SSRF bypass class — on hosts "
52 "configured with NAT64 routes, attacker-supplied URLs can wrap "
53 "cloud-metadata or RFC1918 destinations through these prefixes. "
54 "Enable only on IPv6-only deployments (DNS64+NAT64) where "
55 "outbound IPv4 traffic is synthesized through this prefix and "
56 "the operator has accepted the residual SSRF risk. 6to4 "
57 "(2002::/16), Teredo (2001::/32), and the discard prefix "
58 "(100::/64) remain unconditionally blocked because they have no "
59 "live legitimate use in 2026. The cloud-metadata block "
60 "(ALWAYS_BLOCKED_METADATA_IPS) still applies via embedded-IPv4 "
61 "extraction — see SECURITY.md."
62 ),
63 default=False,
64 ),
65 BooleanSetting(
66 key="notifications.allow_outbound",
67 description=(
68 "Master switch for outbound notification webhooks (Apprise). "
69 "Disabled by default because Apprise re-resolves DNS at send time, "
70 "leaving a DNS-rebinding TOCTOU window that cannot be closed in code "
71 "(Apprise exposes no Session/DNS hook). See SECURITY.md "
72 "'Notification Webhook SSRF' for details. Set to true only after "
73 "reviewing the residual risk. Distinct from the per-user "
74 "notifications.enabled toggle in the settings UI: this is the "
75 "server-level operator gate, env-only so it cannot be flipped via "
76 "the user-writable settings API."
77 ),
78 default=False,
79 ),
80 BooleanSetting(
81 key="policy.allow_unprotected_egress",
82 description=(
83 "Allow users to select the UNPROTECTED egress scope. Disabled by "
84 "default; this is an environment-only operator gate and cannot be "
85 "changed through the user-writable settings API. Hard SSRF and "
86 "cloud-metadata protections remain active when enabled."
87 ),
88 default=False,
89 ),
90 BooleanSetting(
91 key="search.allow_private_engine_urls",
92 description=(
93 "Allow user-editable PUBLIC search-engine URLs (e.g. the SearXNG "
94 "instance_url) to point at private / loopback / link-local "
95 "addresses. Environment-only operator gate so it cannot be flipped "
96 "through the user-writable settings API. Disabled by default: a "
97 "public-nature engine (its data source is the public internet) "
98 "pointed at an internal host lets any authenticated user turn a "
99 "research run into an internal port scan / service probe, breaking "
100 "the PUBLIC_ONLY egress promise. Enable only when you self-host "
101 "SearXNG on localhost/LAN and accept that engine fetches may reach "
102 "your private network. To approve only specific origins instead "
103 "of all private addresses, use the finer-grained "
104 "LDR_SEARCH_PRIVATE_ENGINE_URL_ALLOWLIST. "
105 "Docker deployments instead pin the URL "
106 "directly via LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_"
107 "INSTANCE_URL, which is trusted as operator-provisioned. "
108 "Cloud-metadata endpoints (ALWAYS_BLOCKED_METADATA_IPS) stay "
109 "blocked regardless of this flag."
110 ),
111 default=False,
112 ),
113 StringSetting(
114 key="search.private_engine_url_allowlist",
115 description=(
116 "Comma-separated list of exact URL origins "
117 "(scheme://host[:port]) that user-editable PUBLIC search-engine "
118 "URLs (e.g. the SearXNG instance_url) may use even though they "
119 "are private / loopback / link-local — e.g. "
120 "'http://localhost:8080,http://192.168.1.5:8888'. Finer-grained "
121 "alternative to LDR_SEARCH_ALLOW_PRIVATE_ENGINE_URLS=true: "
122 "private egress is enabled for an engine only when its "
123 "configured URL matches a listed origin (the engine's request "
124 "chain, including redirects, then runs with private access — "
125 "like the blanket flag, but conditional on the match), rather "
126 "than for any private URL. Matching is exact scheme + host + port "
127 "(default ports 80/443 implied, host case-insensitive, "
128 "path/query ignored); no wildcards or CIDR ranges. Entries that "
129 "fail to parse are ignored. Environment-only operator setting "
130 "so it cannot be edited through the user-writable settings API. "
131 "Cloud-metadata endpoints (ALWAYS_BLOCKED_METADATA_IPS) and "
132 "non-http(s) schemes stay blocked even if listed."
133 ),
134 default=None,
135 ),
136 BooleanSetting(
137 key="research_library.allow_filesystem_pdf_storage",
138 description=(
139 "Allow users to select the UNENCRYPTED 'filesystem' PDF storage "
140 "mode for library-downloaded PDFs. Disabled by default; this is "
141 "an environment-only operator gate and cannot be changed through "
142 "the user-writable settings API. Filesystem mode writes fetched "
143 "third-party PDFs as PLAINTEXT to a shared library directory "
144 "(cleartext storage of sensitive information, CWE-312). When "
145 "off, the 'filesystem' option is withheld from the settings UI "
146 "and any stored or "
147 "environment value of 'filesystem' is coerced to the encrypted "
148 "'database' mode at write time. Previously-written plaintext "
149 "files remain readable. Enable only when the library directory "
150 "lives on an operator-controlled encrypted volume."
151 ),
152 default=False,
153 ),
154 BooleanSetting(
155 key="research_library.allow_shared_library",
156 description=(
157 "Allow shared-library mode, which drops the per-user library "
158 "directory boundary so all users' downloaded PDFs live in one "
159 "shared directory. Disabled by default; this is an "
160 "environment-only operator gate and cannot be enabled through the "
161 "user-writable settings API. Because both the shared_library flag "
162 "and the storage_path are otherwise user-editable, leaving shared "
163 "mode user-toggleable would let a multi-tenant user point their "
164 "own storage_path at another user's directory and read/overwrite "
165 "their PDFs. When off, the per-user subdirectory is always "
166 "enforced regardless of the user's shared_library setting. Enable "
167 "only on single-tenant or mutually-trusted deployments."
168 ),
169 default=False,
170 ),
171 BooleanSetting(
172 # NOTE: risk-bearing opt-in. Enabling this re-opens a cross-tenant
173 # library-PDF READ. The description below is the operator-facing
174 # warning; keep the two in sync. Env var (auto-derived from the key):
175 # LDR_RESEARCH_LIBRARY_ALLOW_LEGACY_READ_FALLBACK.
176 key="research_library.allow_legacy_read_fallback",
177 description=(
178 "SECURITY RISK — leave DISABLED (default) on any multi-user or "
179 "untrusted deployment. When ENABLED, library PDF reads that miss "
180 "in the caller's own per-user directory fall back to a shared "
181 "root derived from the user-editable "
182 "'research_library.storage_path' setting. On a multi-user "
183 "instance this re-opens a CROSS-TENANT READ: a user can edit "
184 "their own storage_path to point at another user's library "
185 "directory, and because per-user autoincrement resource ids "
186 "collide by construction (e.g. 'pdfs/5.pdf'), a read of their own "
187 "document id then resolves — via this fallback — to the OTHER "
188 "user's PDF and returns its contents. The safe state is OFF: with "
189 "the gate off, reads resolve strictly within the requesting "
190 "user's own per-user root and the fallback never fires. This is "
191 "an environment-only operator gate and cannot be enabled through "
192 "the user-writable settings API. Enable ONLY on a single-user or "
193 "fully-trusted deployment that needs PDFs downloaded before "
194 "per-user isolation (issue #5521) to keep loading from the legacy "
195 "shared location."
196 ),
197 default=False,
198 ),
199]