|
| 1 | +<!-- |
| 2 | + - SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 3 | + - SPDX-License-Identifier: GPL-2.0-or-later |
| 4 | +--> |
| 5 | + |
| 6 | +# Local Network Permission Handling |
| 7 | + |
| 8 | +## Purpose |
| 9 | + |
| 10 | +On macOS 15 and later, the user can deny an application access to devices on |
| 11 | +the local network. A connection attempt affected by this setting otherwise |
| 12 | +looks much like an unreachable server to the desktop client. The local network |
| 13 | +permission check lets the client replace a generic connection error with an |
| 14 | +actionable message: |
| 15 | + |
| 16 | +> Local Network access is disabled. Enable it in System Settings → Privacy & |
| 17 | +> Security → Local Network. |
| 18 | +
|
| 19 | +This check is diagnostic rather than proactive. It runs after a server |
| 20 | +connection has already failed or timed out. It does not request permission and |
| 21 | +does not report a global permission state. Apple does not provide a general API |
| 22 | +for querying that state; instead, the check observes the path of a connection |
| 23 | +to the server the user entered. See |
| 24 | +[TN3179: Understanding local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy). |
| 25 | + |
| 26 | +The implementation was introduced in response to |
| 27 | +[nextcloud/desktop#10452](https://github.com/nextcloud/desktop/issues/10452). |
| 28 | + |
| 29 | +## Architecture |
| 30 | + |
| 31 | +### Platform boundary |
| 32 | + |
| 33 | +`LocalNetworkPermission` exposes two functions from |
| 34 | +`src/gui/localnetworkpermission.h`: |
| 35 | + |
| 36 | +- `checkDeniedForConnection()` reports through a callback whether local network |
| 37 | + permission denied a specific failed connection. |
| 38 | +- `deniedError()` returns the platform-appropriate error shown to the user. |
| 39 | + |
| 40 | +CMake selects the implementation: |
| 41 | + |
| 42 | +- On macOS, `src/gui/macOS/localnetworkpermission.mm` uses Network.framework. |
| 43 | +- On other platforms, `src/gui/localnetworkpermission.cpp` reports `false`, |
| 44 | + preserving the existing connection error. |
| 45 | + |
| 46 | +This keeps platform conditionals out of `ConnectionValidator` and |
| 47 | +`AccountWizardController`. A `false` result means that local network denial was |
| 48 | +not established; it does not prove that permission is enabled. |
| 49 | + |
| 50 | +### macOS connection probe |
| 51 | + |
| 52 | +The macOS implementation is available on macOS 15 and later. It performs the |
| 53 | +following steps: |
| 54 | + |
| 55 | +1. Extract the host and port from the failed URL. The default port is `443` for |
| 56 | + HTTPS and `80` otherwise. |
| 57 | +2. Create a Network.framework TCP connection to that endpoint. The probe does |
| 58 | + not perform an HTTP request or a TLS handshake. |
| 59 | +3. Set `prefer_no_proxy` on the connection parameters. This makes |
| 60 | + Network.framework try the direct path first, so a VPN-provided local proxy |
| 61 | + cannot immediately hide the local-network denial. Network.framework may |
| 62 | + still try a configured proxy if the direct attempt fails. |
| 63 | +4. Observe connection path and state updates on the main dispatch queue. |
| 64 | +5. Finish with `true` when an unsatisfied path reports |
| 65 | + `nw_path_unsatisfied_reason_local_network_denied`. |
| 66 | +6. Finish with `false` when the connection becomes ready. |
| 67 | +7. On a `waiting` or `failed` state, finish only if the current path explicitly |
| 68 | + reports local-network denial. A path can be temporarily inconclusive, so |
| 69 | + completing with `false` at this point would introduce a race with a later |
| 70 | + path update. |
| 71 | +8. After two seconds, inspect the path once more and finish. This bounds the |
| 72 | + diagnostic delay when the server is merely absent or unreachable. |
| 73 | + |
| 74 | +`ConnectionProbe` owns the Network.framework connection and callback. Its |
| 75 | +`completed` flag ensures exactly-once completion. Finishing cancels and |
| 76 | +releases the connection before dispatching the result back through Qt. |
| 77 | + |
| 78 | +The callback context is held as a `QPointer<QObject>`. The result is queued onto |
| 79 | +that context and is discarded if the context has been destroyed, preventing a |
| 80 | +callback into a deleted controller or validator. |
| 81 | + |
| 82 | +### Consumers |
| 83 | + |
| 84 | +`ConnectionValidator` invokes the check when: |
| 85 | + |
| 86 | +- the status request fails; or |
| 87 | +- its connection job times out. |
| 88 | + |
| 89 | +When denial is established, the permission message replaces the generic |
| 90 | +network error. The validator's status value is unchanged. |
| 91 | + |
| 92 | +`AccountWizardController` invokes the check after its server connection fails. |
| 93 | +When denial is established, it displays the permission message and does not |
| 94 | +offer secure-connection recovery, such as retrying without TLS. Otherwise, the |
| 95 | +existing recovery flow continues. |
| 96 | + |
| 97 | +The wizard also verifies that the account URL still matches the URL whose |
| 98 | +probe completed. This prevents a delayed result from an earlier attempt from |
| 99 | +changing the state of a newer attempt. |
| 100 | + |
| 101 | +### Test seam |
| 102 | + |
| 103 | +Both consumers store the permission check in a private `std::function`, |
| 104 | +initialized to `LocalNetworkPermission::checkDeniedForConnection`. Their test |
| 105 | +access classes are friends and replace that callable with a synchronous |
| 106 | +deterministic result. |
| 107 | + |
| 108 | +This keeps the production constructors and public API unchanged. It also tests |
| 109 | +the behavior of each consumer without subclassing production classes or |
| 110 | +making one-line methods virtual solely for tests. |
| 111 | + |
| 112 | +## Test plan |
| 113 | + |
| 114 | +### Automated coverage |
| 115 | + |
| 116 | +`AccountWizardControllerTest` covers: |
| 117 | + |
| 118 | +- An invalid URL produces a non-denied result. |
| 119 | +- A denied result displays `deniedError()` and suppresses secure-connection |
| 120 | + recovery. |
| 121 | +- A non-denied result preserves the secure-connection recovery flow. |
| 122 | + |
| 123 | +`ConnectionValidatorTest` covers: |
| 124 | + |
| 125 | +- A denied result replaces the generic timeout text with `deniedError()`. |
| 126 | + |
| 127 | +Build and run the focused tests from the repository root: |
| 128 | + |
| 129 | +```sh |
| 130 | +cmake -S . -B build-testing |
| 131 | +cmake --build build-testing \ |
| 132 | + --target AccountWizardControllerTest ConnectionValidatorTest |
| 133 | +ctest --test-dir build-testing --output-on-failure \ |
| 134 | + -R '^(ConnectionValidator|AccountWizardController)Test$' |
| 135 | +``` |
| 136 | + |
| 137 | +### Manual coverage |
| 138 | + |
| 139 | +The automated tests do not cover: |
| 140 | + |
| 141 | +- macOS Local Network privacy enforcement or its System Settings toggle; |
| 142 | +- Network.framework path-update ordering and unsatisfied reasons; |
| 143 | +- behavior with a real VPN or system proxy; |
| 144 | +- the direct-path preference and proxy fallback; |
| 145 | +- code-signing identity and executable UUID tracking; |
| 146 | +- differences between launching from Finder, Terminal, Xcode, or another |
| 147 | + parent process; |
| 148 | +- the complete two-second probe against a real network. |
| 149 | + |
| 150 | +These behaviors depend on operating-system privacy state, routing, signing, |
| 151 | +and the active network environment. Checking only that a Network.framework |
| 152 | +parameter was set would test an implementation detail, not the intended VPN |
| 153 | +behavior. The native path therefore requires the manual regression test below. |
| 154 | + |
| 155 | +## Reproducing local-network denial |
| 156 | + |
| 157 | +### Requirements |
| 158 | + |
| 159 | +- macOS 15 or later. |
| 160 | +- A validly signed application with a stable Apple-issued identity. |
| 161 | +- A unique UUID in the main executable. |
| 162 | +- `NSLocalNetworkUsageDescription` in the application `Info.plist`. |
| 163 | +- A target address on a network directly attached through Wi-Fi or Ethernet. |
| 164 | + A private address routed elsewhere is not necessarily a local-network |
| 165 | + address for this privacy feature. |
| 166 | + |
| 167 | +The target does not need to run a Nextcloud server. Using an unused address on |
| 168 | +the directly attached subnet is useful because it isolates privacy diagnosis |
| 169 | +from server behavior. |
| 170 | + |
| 171 | +Verify the application before testing: |
| 172 | + |
| 173 | +```sh |
| 174 | +codesign --verify --deep --strict --verbose=2 /path/to/Nextcloud.app |
| 175 | +/usr/bin/dwarfdump --uuid /path/to/Nextcloud.app/Contents/MacOS/Nextcloud |
| 176 | +``` |
| 177 | + |
| 178 | +Both commands must succeed, and the UUID output must not be empty. |
| 179 | + |
| 180 | +### Launch the application correctly |
| 181 | + |
| 182 | +Quit all running instances and launch the tested application by |
| 183 | +double-clicking its bundle in Finder. |
| 184 | + |
| 185 | +Do not start the executable directly from Terminal or SSH. macOS automatically |
| 186 | +allows local-network access for command-line tools launched from those |
| 187 | +environments and for their child processes. In that situation, |
| 188 | +Network.framework can report the connection as ineligible for privacy |
| 189 | +enforcement even though the application's Local Network toggle is disabled. |
| 190 | +This produces a false-negative test. |
| 191 | + |
| 192 | +For the same reason, Finder launch is preferred for this regression test over |
| 193 | +developer launch mechanisms whose responsible process may affect privacy |
| 194 | +attribution. |
| 195 | + |
| 196 | +### Test procedure |
| 197 | + |
| 198 | +1. Determine the Mac's Wi-Fi or Ethernet address and subnet. |
| 199 | +2. Choose an unused address on that same directly attached subnet. |
| 200 | +3. Open **System Settings → Privacy & Security → Local Network**. |
| 201 | +4. Disable Local Network access for Nextcloud. |
| 202 | +5. Quit Nextcloud, then launch the tested app bundle from Finder. |
| 203 | +6. In the account wizard, enter the unused address, for example |
| 204 | + `https://192.168.0.64`. |
| 205 | +7. Start the connection. |
| 206 | + |
| 207 | +Expected result: |
| 208 | + |
| 209 | +- The wizard reports that Local Network access is disabled. |
| 210 | +- It does not show the secure-connection recovery dialog. |
| 211 | + |
| 212 | +Repeat with a VPN active. The expected result is the same. The permission probe |
| 213 | +should try the direct local route before a VPN-provided proxy can handle the |
| 214 | +connection. |
| 215 | + |
| 216 | +As a comparison, enable Local Network access and repeat. Because the chosen |
| 217 | +address has no server, the result should now be an ordinary timeout or |
| 218 | +connection failure rather than the permission message. |
| 219 | + |
| 220 | +## Troubleshooting |
| 221 | + |
| 222 | +### The result is a timeout or TLS recovery dialog |
| 223 | + |
| 224 | +Confirm all of the following: |
| 225 | + |
| 226 | +- The application was launched from Finder, not by executing its binary in a |
| 227 | + shell. |
| 228 | +- The tested bundle has a valid signature. |
| 229 | +- The running process belongs to the bundle just verified. |
| 230 | +- The target address is on a directly attached Wi-Fi or Ethernet subnet. |
| 231 | +- The Local Network toggle for the tested application is disabled. |
| 232 | + |
| 233 | +### Inspect Network.framework activity |
| 234 | + |
| 235 | +The following command reads the relevant unified logs for a bounded test |
| 236 | +interval: |
| 237 | + |
| 238 | +```sh |
| 239 | +/usr/bin/log show \ |
| 240 | + --start '2026-07-27 22:20:45' \ |
| 241 | + --end '2026-07-27 22:22:35' \ |
| 242 | + --style compact --info --debug \ |
| 243 | + --predicate 'process == "Nextcloud" AND subsystem BEGINSWITH "com.apple.network"' |
| 244 | +``` |
| 245 | + |
| 246 | +Replace the timestamps with the actual test interval. Useful evidence includes: |
| 247 | + |
| 248 | +- `prefer no proxy`, confirming that the direct path preference is active; |
| 249 | +- `local network denied` or an unsatisfied local-network-denial reason; |
| 250 | +- a proxy endpoint such as `127.0.0.1`, showing proxy fallback; |
| 251 | +- `Privacy Stance: Not Eligible`, which indicates that the operation was not |
| 252 | + subject to normal Local Network privacy enforcement and commonly points to |
| 253 | + the launch or identity conditions described above. |
| 254 | + |
| 255 | +### VPN interpretation |
| 256 | + |
| 257 | +A VPN can install a system proxy even when the local target remains routed over |
| 258 | +Wi-Fi. `prefer_no_proxy` means “try direct first,” not “prohibit all proxies.” |
| 259 | +Seeing a later proxy attempt is therefore expected when the direct attempt |
| 260 | +fails. What matters for this feature is that macOS has an opportunity to |
| 261 | +evaluate the direct local path and report denial before proxy fallback masks |
| 262 | +the original condition. |
0 commit comments