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

1""" 

2Security environment settings. 

3 

4These settings control security-related behavior like SSRF validation 

5and CORS origin restrictions. 

6""" 

7 

8import os 

9from ..env_settings import BooleanSetting, StringSetting 

10 

11 

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") 

15 

16 

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]