feat(sync): http:// limite au reseau local, Basic des la 1re requete

APK : le clair est permis par Android et trie dans TSyncClient.RefusalReason
(IsLocalHost : RFC 1918, 127.x, .local, .lan, nom sans point) - chacun pointe
vers son propre WebDAV sans IP connue a la compilation ; vers Internet, HTTPS.
PC : Authorization Basic envoye d'emblee, plus de 401 anonyme ni de PUT du
coffre envoye deux fois.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-10-02 23:26:17 +01:00
parent 3051c3c392
commit 0604344c60
4 changed files with 79 additions and 32 deletions
+54 -4
View File
@@ -56,6 +56,11 @@ type
Error: string; // message brut, pour le journal Error: string; // message brut, pour le journal
end; end;
/// Vrai pour une adresse du reseau local (RFC 1918, boucle, .local, .lan,
/// nom sans point) : le seul endroit ou le HTTP en clair est accepte.
function IsLocalHost(const AHost: string): Boolean;
type
TSyncClient = class TSyncClient = class
private private
FBaseUrl: string; FBaseUrl: string;
@@ -64,6 +69,7 @@ type
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse. /// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
FConnectMs: Integer; FConnectMs: Integer;
FPrivacyChecked: Boolean; FPrivacyChecked: Boolean;
function PublicRepoWarning: string;
public public
constructor Create(const ABaseUrl, AToken: string); constructor Create(const ABaseUrl, AToken: string);
@@ -82,11 +88,12 @@ type
/// l'ecriture (412 = un autre appareil a publie entre-temps). /// l'ecriture (412 = un autre appareil a publie entre-temps).
function IsWebDav: Boolean; function IsWebDav: Boolean;
/// Refus LOCAL, avant tout envoi : http:// hors reseau local, puis depot public.
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un /// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
/// commit, et l'historique garde tous les instantanes, attaquables hors /// commit, et l'historique garde tous les instantanes, attaquables hors
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot /// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
/// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance. /// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance.
function PublicRepoWarning: string; function RefusalReason: string;
/// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404 /// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404
/// aussi bien pour un fichier absent que pour un depot ou un proprietaire /// aussi bien pour un fichier absent que pour un depot ou un proprietaire
@@ -113,7 +120,7 @@ type
implementation implementation
uses uses
System.JSON, System.NetEncoding; System.JSON, System.NetEncoding, System.StrUtils;
function TSyncClient.IsGitea: Boolean; function TSyncClient.IsGitea: Boolean;
begin begin
@@ -207,6 +214,49 @@ begin
if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := ''; if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := '';
end; end;
/// HTTP EN CLAIR : RESEAU LOCAL SEULEMENT. Android le permet partout
/// (network_security_config) pour que chacun pointe vers SON serveur WebDAV,
/// a une adresse qu'on ne connait pas a la compilation ; le tri se fait donc
/// ici. Vers Internet, le jeton ne part qu'en HTTPS.
/// ponytail: un Wi-Fi etranger qui reutilise la meme plage recevrait le jeton
/// en clair - le coffre, lui, reste chiffre. HTTPS chez soi supprime le risque.
function IsLocalHost(const AHost: string): Boolean;
var
LParts: TArray<string>;
A, B: Integer;
begin
var LHost := AHost.ToLower;
if (LHost = 'localhost') or LHost.EndsWith('.local') or LHost.EndsWith('.lan') or
(LHost.IndexOf('.') < 0) and (LHost <> '') then
Exit(True);
LParts := LHost.Split(['.']);
if (Length(LParts) <> 4) or not TryStrToInt(LParts[0], A) or
not TryStrToInt(LParts[1], B) then
Exit(False);
for var LPart in LParts do
if not TryStrToInt(LPart, A) then Exit(False);
A := StrToInt(LParts[0]);
Result := (A = 10) or (A = 127) or ((A = 192) and (B = 168)) or
((A = 172) and (B >= 16) and (B <= 31));
end;
function TSyncClient.RefusalReason: string;
var
LHost: string;
begin
if StartsText('http://', FBaseUrl) then
begin
try
LHost := TURI.Create(FBaseUrl).Host;
except
LHost := '';
end;
if not IsLocalHost(LHost) then
Exit('http:// is only allowed on the local network (192.168.x.x, 10.x.x.x, .local, .lan) - use https:// for ' + LHost);
end;
Result := PublicRepoWarning;
end;
function TSyncClient.PublicRepoWarning: string; function TSyncClient.PublicRepoWarning: string;
var var
LHttp: THTTPClient; LHttp: THTTPClient;
@@ -389,7 +439,7 @@ begin
Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)'; Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)';
Exit; Exit;
end; end;
Result.Error := PublicRepoWarning; Result.Error := RefusalReason;
if Result.Error <> '' then if Result.Error <> '' then
begin begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
@@ -522,7 +572,7 @@ begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; end;
Result.Error := PublicRepoWarning; Result.Error := RefusalReason;
if Result.Error <> '' then if Result.Error <> '' then
begin begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
+9 -4
View File
@@ -434,10 +434,15 @@ l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl` - **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt, (elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
`/src/branch/…` et `/blob/…`. `/src/branch/…` et `/blob/…`.
- **Clair autorisé vers `192.168.1.28` seulement** (IP fixe du poste), dans - **`http://` : réseau local seulement**, pour que chacun (les amis compris)
`network_security_config.xml` : le mot de passe WebDAV passe en clair sur le pointe vers son propre serveur sans adresse connue à la compilation. Android
Wi-Fi, le coffre reste chiffré. À retirer quand le serveur parlera HTTPS. ne sait autoriser ni plage ni masque : `network_security_config.xml` permet
Android ne sait pas autoriser « le réseau local » : chaque adresse se nomme. donc le clair partout, et `TSyncClient.RefusalReason` refuse **avant tout
envoi** un `http://` dont l'hôte n'est pas local (`IsLocalHost` : RFC 1918,
`127.x`, `.local`, `.lan`, nom sans point). Vers Internet, HTTPS obligatoire,
et le refus s'affiche dans les réglages. Risque assumé : un Wi-Fi étranger
qui réutilise la même plage recevrait le mot de passe WebDAV en clair — le
coffre, lui, reste chiffré.
- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et** - **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et**
Portmaster (entrant autorisé pour Python) — sans le second, le port restait Portmaster (entrant autorisé pour Python) — sans le second, le port restait
fermé malgré la règle. fermé malgré la règle.
@@ -1,30 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit Le clair est PERMIS ICI, et trie dans le code (PMA.SyncClient.IsLocalHost).
partout ailleurs.
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour Chacun pointe vers SON serveur WebDAV, souvent en http:// sur son reseau
une seule raison : l'essai local passe par "adb reverse", qui fait voir le local, a une adresse qu'on ne connait pas a la compilation - et Android ne
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la sait autoriser ni une plage ni un masque, seulement des noms un par un.
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant Avant tout envoi, la synchro refuse donc tout http:// qui ne vise pas le
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale. reseau local (RFC 1918, boucle, .local, .lan, nom sans point) : vers
Internet, le jeton ne part qu'en HTTPS, et le refus s'affiche au lieu de
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et laisser passer en silence.
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
exactement le comportement voulu : bruyant, pas silencieux.
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
l'intention noir sur blanc.
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="false" /> <base-config cleartextTrafficPermitted="true" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<!-- ponytail: WebDAV local du poste (IP fixe), en clair sur le Wi-Fi.
Le coffre reste chiffre ; le mot de passe WebDAV, lui, passe en
clair. A retirer quand le serveur parlera HTTPS. -->
<domain includeSubdomains="false">192.168.1.28</domain>
</domain-config>
</network-security-config> </network-security-config>
+7
View File
@@ -1757,6 +1757,13 @@ begin
end end
else if (LUser <> '') then else if (LUser <> '') then
begin begin
// BASIC DES LA PREMIERE REQUETE, comme le telephone. Laisse a
// Windows, l'authentification partait d'abord en anonyme : 401,
// puis la meme requete - et un PUT envoyait le coffre deux fois.
// Les identifiants restent aussi en reserve pour un serveur Digest.
LHttp.CustomHeaders['Authorization'] := 'Basic ' +
TNetEncoding.Base64.Encode(LUser + ':' + LPwd)
.Replace(#13, '').Replace(#10, '');
LHttp.CredentialsStorage.AddCredential( LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create( System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd)); System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));