unit PM.HTTPServer; { Indy TIdHTTPServer wrapper. - Binds 127.0.0.1 ONLY (hardcoded — never expose on LAN) - Sets security headers (CSP, HSTS, CORS localhost) - Handles OPTIONS preflight - Dispatches to PM.Router; 404 if no match } interface uses System.SysUtils, System.Classes, System.IOUtils, Winapi.Windows, IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, IdTCPConnection, PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets, PM.Crypto, PM.ProcessLockdown, PM.Session; type TLogProc = reference to procedure(const AMsg: string); TPMHTTPServer = class private FServer: TIdHTTPServer; FOnLog: TLogProc; FAccessToken: string; FRequireAccessToken: Boolean; FRequireProcessCheck: Boolean; FBoundPort: Integer; procedure HandleCommand(AContext: TIdContext; ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); procedure HandleCommandOther(AContext: TIdContext; ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); procedure HandleParseAuthentication(AContext: TIdContext; const AAuthType, AAuthData: string; var VUsername, VPassword: string; var VHandled: Boolean); procedure HandleException(AContext: TIdContext; AException: Exception); procedure ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); procedure Log(const AMsg: string); function GetActive: Boolean; function ValidateAccessToken(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo): Boolean; function ValidateConnectingProcess(AContext: TIdContext; AResponse: TIdHTTPResponseInfo): Boolean; public constructor Create; destructor Destroy; override; procedure Start(APort: Integer; SameFolder: Boolean; ARequireAccessToken: Boolean = True; ARequireProcessCheck: Boolean = True); procedure Stop; property Active: Boolean read GetActive; property OnLog: TLogProc read FOnLog write FOnLog; property AccessToken: string read FAccessToken; property RequireAccessToken: Boolean read FRequireAccessToken; property RequireProcessCheck: Boolean read FRequireProcessCheck; property BoundPort: Integer read FBoundPort; end; implementation constructor TPMHTTPServer.Create; begin inherited; FServer := TIdHTTPServer.Create(nil); FServer.OnCommandGet := HandleCommand; FServer.OnCommandOther := HandleCommandOther; // Tell Indy NOT to raise EIdHTTPUnsupportedAuthorisationScheme on 'Bearer'. // We parse the Authorization header ourselves in PM.Session. FServer.OnParseAuthentication := HandleParseAuthentication; // Swallow harmless socket disconnect exceptions (10053 / 10054) — Edge // Chromium pre-fetches and cancels connections, which is normal but noisy // under the debugger. FServer.OnException := HandleException; end; procedure TPMHTTPServer.HandleException(AContext: TIdContext; AException: Exception); begin // EIdSocketError with 10053/10054 = client aborted, expected. Log everything // else. if (AException.ClassName = 'EIdSocketError') or (AException.ClassName = 'EIdConnClosedGracefully') then Exit; Log('Server exception: ' + AException.ClassName + ' - ' + AException.Message); end; procedure TPMHTTPServer.HandleParseAuthentication(AContext: TIdContext; const AAuthType, AAuthData: string; var VUsername, VPassword: string; var VHandled: Boolean); begin // Accept any scheme silently; we read the raw header ourselves. VHandled := True; end; destructor TPMHTTPServer.Destroy; begin Stop; FServer.Free; inherited; end; function TPMHTTPServer.GetActive: Boolean; begin Result := Assigned(FServer) and FServer.Active; end; procedure TPMHTTPServer.Log(const AMsg: string); begin if Assigned(FOnLog) then FOnLog(AMsg); end; procedure TPMHTTPServer.Start(APort: Integer; SameFolder: Boolean; ARequireAccessToken: Boolean; ARequireProcessCheck: Boolean); const EphemeralPortMin = 49152; EphemeralPortMax = 65535; var LBinding: TIdSocketHandle; LDBPath, LWebRoot, LResolvedPort: string; LRequestedPort: Integer; begin if FServer.Active then Exit; FRequireAccessToken := ARequireAccessToken; FRequireProcessCheck := ARequireProcessCheck; if FRequireAccessToken then FAccessToken := PM.Crypto.RandomHex(32) else FAccessToken := ''; LRequestedPort := APort; if LRequestedPort = 0 then LRequestedPort := EphemeralPortMin + Random(EphemeralPortMax - EphemeralPortMin); var pathParent := '..\'; if SameFolder then pathParent := ''; LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent+'vault.db')); LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent)); Log('Opening database: ' + LDBPath); InitDatabase(LDBPath); Log('Database ready.'); Log('Web root: ' + LWebRoot); InitStaticServer(LWebRoot); FServer.Bindings.Clear; LBinding := FServer.Bindings.Add; LBinding.IP := '127.0.0.1'; LBinding.Port := LRequestedPort; FServer.Active := True; FBoundPort := LRequestedPort; LResolvedPort := IntToStr(FBoundPort); Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)', [LResolvedPort, BoolToStr(FRequireAccessToken, True), BoolToStr(FRequireProcessCheck, True)])); end; procedure TPMHTTPServer.Stop; begin if not Assigned(FServer) then Exit; if FServer.Active then begin FServer.Active := False; Log('Server stopped.'); end; end; procedure TPMHTTPServer.ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); var LOrigin: string; begin AResponse.CustomHeaders.Values['Strict-Transport-Security'] := 'max-age=31536000; includeSubDomains'; // Content-Security-Policy — tightened May 2026: // - script-src: removed 'unsafe-inline'. No