mirror of
https://github.com/cheat-engine/cheat-engine
synced 2026-08-15 02:26:08 -04:00
508 lines
20 KiB
ObjectPascal
508 lines
20 KiB
ObjectPascal
unit AIHttpClient;
|
|
|
|
{$mode objfpc}{$H+}
|
|
|
|
// Windows-native HTTPS transport for the AI chat feature. UTF-8 in/out.
|
|
// Pure request/response; no threads, no globals -> safe to call from a worker thread.
|
|
//
|
|
// Security contract (mirrors AIConfig's URL policy):
|
|
// - HTTPS required, except literal loopback (localhost, 127/8, [::1]) may use HTTP.
|
|
// - userinfo in the authority is rejected outright (no credential leakage via URL).
|
|
// - TLS certificate validation is NEVER disabled.
|
|
// - Automatic redirects are disabled AND any 3xx is treated as failure, so secret
|
|
// headers can never be replayed to a different host.
|
|
// - Response is capped at 8 MiB before any append/allocation.
|
|
// - Outbound header lines are validated (no CR/LF, valid field-name) before any send.
|
|
// - Sensitive header values (Authorization, x-api-key) are redacted from all errors.
|
|
// - On any non-2xx, ResponseBody is cleared; a bounded (<=2048 B), redacted excerpt
|
|
// rides in ErrorText only.
|
|
// Non-Windows builds return an explicit "unsupported" error with no network fallback.
|
|
|
|
interface
|
|
|
|
uses
|
|
{$IFDEF WINDOWS}Windows,{$ENDIF}
|
|
Classes, SysUtils;
|
|
|
|
// Returns True only on an HTTP 2xx response. On any other outcome returns False with
|
|
// ErrorText populated (status + a bounded, credential-redacted body excerpt, <=2048 B
|
|
// total) and ResponseBody cleared. Outputs are always initialized, even on exception.
|
|
function PostJSON(const URL: UTF8String; Headers: TStrings; const Body: UTF8String;
|
|
TimeoutMS: DWORD; out StatusCode: DWORD; out ResponseBody: UTF8String;
|
|
out ErrorText: UTF8String): Boolean;
|
|
|
|
{$IFDEF AIHTTP_TEST}
|
|
// Internals exposed for unit tests only.
|
|
type
|
|
TParsedURL = record
|
|
Scheme: UTF8String; // 'http' | 'https'
|
|
Host: UTF8String; // IPv6 literals have brackets stripped ('::1')
|
|
Path: UTF8String; // request target: path plus query, never empty ('/')
|
|
Port: Word;
|
|
Secure: Boolean;
|
|
end;
|
|
|
|
const
|
|
AIHTTP_MAX_RESPONSE = 8 * 1024 * 1024; // 8 MiB hard cap
|
|
AIHTTP_MIN_TIMEOUT = 5000;
|
|
AIHTTP_MAX_TIMEOUT = 300000;
|
|
|
|
function AIHttp_ParseURL(const URL: UTF8String; out P: TParsedURL; out ErrorText: UTF8String): Boolean;
|
|
function AIHttp_Redact(const Text: UTF8String; Headers: TStrings): UTF8String;
|
|
function AIHttp_CapExceeded(CurrentLen, ChunkLen: SizeInt): Boolean;
|
|
function AIHttp_ValidTimeout(TimeoutMS: DWORD): Boolean;
|
|
function AIHttp_ValidHeaderLine(const Line: UTF8String): Boolean;
|
|
|
|
const
|
|
AIHTTP_MAX_ERRTEXT = 2048; // final ErrorText byte cap (after redaction)
|
|
{$ENDIF}
|
|
|
|
implementation
|
|
|
|
{$IFNDEF AIHTTP_TEST}
|
|
// Release builds don't export the test surface, but the transport implementation
|
|
// still needs this record. Under AIHTTP_TEST the interface already declares it, so
|
|
// guard here to keep exactly one definition per build.
|
|
type
|
|
TParsedURL = record
|
|
Scheme: UTF8String; // 'http' | 'https'
|
|
Host: UTF8String; // IPv6 literals have brackets stripped ('::1')
|
|
Path: UTF8String; // request target: path plus query, never empty ('/')
|
|
Port: Word;
|
|
Secure: Boolean;
|
|
end;
|
|
{$ENDIF}
|
|
|
|
const
|
|
MAX_RESPONSE = 8 * 1024 * 1024; // 8 MiB
|
|
MIN_TIMEOUT = 5000;
|
|
MAX_TIMEOUT = 300000;
|
|
MAX_ERRTEXT = 2048; // final ErrorText byte cap (after redaction)
|
|
MAX_ERR_BODY = 1024; // body excerpt appended into ErrorText (bounded, then final-capped)
|
|
REDACT_MARK = '***REDACTED***';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL parsing / loopback policy (self-contained; same semantics as AIConfig)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function IsLoopbackIPv4(const Host: UTF8String): boolean;
|
|
var parts: TStringArray; a, i: integer;
|
|
begin
|
|
Result := False;
|
|
parts := string(Host).Split(['.']); // Host is ASCII for any IPv4 literal
|
|
if Length(parts) <> 4 then Exit;
|
|
if not (TryStrToInt(parts[0], a) and (a = 127)) then Exit;
|
|
for i := 1 to 3 do
|
|
if not (TryStrToInt(parts[i], a) and (a >= 0) and (a <= 255)) then Exit;
|
|
Result := True;
|
|
end;
|
|
|
|
function IsLoopback(const Host: UTF8String): boolean;
|
|
var h: UTF8String;
|
|
begin
|
|
h := LowerCase(Host);
|
|
Result := (h = 'localhost') or (h = '::1') or IsLoopbackIPv4(h);
|
|
end;
|
|
|
|
// Parse a numeric string as a decimal port in 1..65535. Rejects '', signs, junk.
|
|
function ParsePort(const S: UTF8String; out Port: Word): boolean;
|
|
var i, v: integer;
|
|
begin
|
|
Result := False;
|
|
if (S = '') or (Length(S) > 5) then Exit;
|
|
for i := 1 to Length(S) do
|
|
if not (S[i] in ['0'..'9']) then Exit;
|
|
v := StrToInt(S);
|
|
if (v < 1) or (v > 65535) then Exit;
|
|
Port := Word(v);
|
|
Result := True;
|
|
end;
|
|
|
|
function DefaultPort(Secure: boolean): Word;
|
|
begin
|
|
if Secure then Result := 443 else Result := 80;
|
|
end;
|
|
|
|
function AIHttp_ParseURL(const URL: UTF8String; out P: TParsedURL; out ErrorText: UTF8String): Boolean;
|
|
var
|
|
i, sep: integer;
|
|
hasPort: boolean;
|
|
rest, authority, tail, hostport, portStr: UTF8String;
|
|
begin
|
|
Result := False;
|
|
P.Scheme := ''; P.Host := ''; P.Path := ''; P.Port := 0; P.Secure := False;
|
|
ErrorText := '';
|
|
|
|
// scheme
|
|
i := Pos('://', URL);
|
|
if i <= 0 then begin ErrorText := 'URL must include a scheme (https:// or http://)'; Exit; end;
|
|
P.Scheme := LowerCase(Copy(URL, 1, i - 1));
|
|
rest := Copy(URL, i + 3, MaxInt);
|
|
if (P.Scheme <> 'http') and (P.Scheme <> 'https') then
|
|
begin ErrorText := 'URL scheme must be http or https'; Exit; end;
|
|
P.Secure := P.Scheme = 'https';
|
|
|
|
// authority ends at the first '/', '?' or '#'
|
|
sep := 1;
|
|
while (sep <= Length(rest)) and (rest[sep] <> '/') and (rest[sep] <> '?') and (rest[sep] <> '#') do
|
|
Inc(sep);
|
|
authority := Copy(rest, 1, sep - 1);
|
|
tail := Copy(rest, sep, MaxInt);
|
|
|
|
// request target = path + query (fragment dropped, never sent to server)
|
|
i := Pos('#', tail);
|
|
if i > 0 then tail := Copy(tail, 1, i - 1);
|
|
if tail = '' then P.Path := '/'
|
|
else if tail[1] = '?' then P.Path := '/' + tail
|
|
else P.Path := tail;
|
|
|
|
// userinfo is a credential-leak vector -> reject, never strip
|
|
if Pos('@', authority) > 0 then
|
|
begin ErrorText := 'URL must not contain userinfo (user@host)'; Exit; end;
|
|
|
|
// host[:port] — IPv6 literals are bracketed
|
|
hasPort := False;
|
|
portStr := '';
|
|
if (authority <> '') and (authority[1] = '[') then
|
|
begin
|
|
i := Pos(']', authority);
|
|
if i = 0 then begin ErrorText := 'URL has an unterminated IPv6 literal'; Exit; end;
|
|
P.Host := Copy(authority, 2, i - 2); // strip brackets
|
|
hostport := Copy(authority, i + 1, MaxInt); // '' or ':port'
|
|
if hostport <> '' then
|
|
begin
|
|
if hostport[1] <> ':' then
|
|
begin ErrorText := 'URL has a malformed IPv6 authority'; Exit; end;
|
|
hasPort := True;
|
|
portStr := Copy(hostport, 2, MaxInt);
|
|
end;
|
|
end
|
|
else
|
|
begin
|
|
i := Pos(':', authority);
|
|
if i = 0 then P.Host := authority
|
|
else
|
|
begin
|
|
P.Host := Copy(authority, 1, i - 1);
|
|
hasPort := True;
|
|
portStr := Copy(authority, i + 1, MaxInt);
|
|
if Pos(':', portStr) > 0 then // stray colon -> ambiguous
|
|
begin ErrorText := 'URL has a malformed authority'; Exit; end;
|
|
end;
|
|
end;
|
|
|
|
if P.Host = '' then begin ErrorText := 'URL is missing a host'; Exit; end;
|
|
|
|
if not hasPort then
|
|
P.Port := DefaultPort(P.Secure)
|
|
else if not ParsePort(portStr, P.Port) then // present but empty/invalid -> reject
|
|
begin ErrorText := 'URL has an invalid port'; Exit; end;
|
|
|
|
// HTTP only for loopback
|
|
if (not P.Secure) and (not IsLoopback(P.Host)) then
|
|
begin ErrorText := 'HTTP is only allowed for loopback; use HTTPS for remote hosts'; Exit; end;
|
|
|
|
Result := True;
|
|
end;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Redaction / cap / timeout helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Redact every non-blank value of a sensitive header (Authorization, x-api-key),
|
|
// matching header names case-insensitively. The value is the exact secret string, so
|
|
// redaction of the value is a literal (case-sensitive) substring replace.
|
|
function AIHttp_Redact(const Text: UTF8String; Headers: TStrings): UTF8String;
|
|
var
|
|
i, c: integer;
|
|
line, name, val: UTF8String;
|
|
begin
|
|
Result := Text;
|
|
if Headers = nil then Exit;
|
|
for i := 0 to Headers.Count - 1 do
|
|
begin
|
|
line := Headers[i];
|
|
c := Pos(':', line);
|
|
if c <= 0 then Continue;
|
|
name := LowerCase(Trim(Copy(line, 1, c - 1)));
|
|
if (name <> 'authorization') and (name <> 'x-api-key') then Continue;
|
|
val := Trim(Copy(line, c + 1, MaxInt));
|
|
if val = '' then Continue; // nothing to redact
|
|
Result := StringReplace(Result, val, REDACT_MARK, [rfReplaceAll]);
|
|
end;
|
|
end;
|
|
|
|
// A single outbound header line is well-formed iff it contains no CR/LF (header
|
|
// injection) and has a non-empty, syntactically valid field-name before the first
|
|
// colon. field-name is an RFC 7230 token (tchar+); anything else is rejected so a
|
|
// crafted value cannot smuggle an extra Authorization/x-api-key line the redactor
|
|
// can never enumerate. Value part is not constrained here beyond the CR/LF ban.
|
|
function AIHttp_ValidHeaderLine(const Line: UTF8String): Boolean;
|
|
var i, c: integer; ch: Char;
|
|
begin
|
|
Result := False;
|
|
for i := 1 to Length(Line) do
|
|
if (Line[i] = #13) or (Line[i] = #10) or (Line[i] = #0) then Exit;
|
|
// no CR/LF -> no injected lines; no NUL -> WinHTTP's NUL-terminated view of the
|
|
// wide header can't diverge from the redactor's view of the full Pascal string
|
|
c := Pos(':', Line);
|
|
if c <= 1 then Exit; // missing colon or empty field-name
|
|
for i := 1 to c - 1 do
|
|
begin
|
|
ch := Line[i];
|
|
if not (ch in ['A'..'Z','a'..'z','0'..'9',
|
|
'!','#','$','%','&','''','*','+','-','.','^','_','`','|','~']) then Exit; // tchar only
|
|
end;
|
|
Result := True;
|
|
end;
|
|
|
|
// Overflow-safe: would appending ChunkLen bytes to CurrentLen exceed the 8 MiB cap?
|
|
function AIHttp_CapExceeded(CurrentLen, ChunkLen: SizeInt): Boolean;
|
|
begin
|
|
if (CurrentLen < 0) or (ChunkLen < 0) then Exit(True);
|
|
if CurrentLen > MAX_RESPONSE then Exit(True);
|
|
Result := ChunkLen > (MAX_RESPONSE - CurrentLen);
|
|
end;
|
|
|
|
// Timeout contract is a hard range, not a clamp: out-of-range is a caller error
|
|
// (a silent clamp would mask a misconfigured 0 or an absurd value).
|
|
function AIHttp_ValidTimeout(TimeoutMS: DWORD): Boolean;
|
|
begin
|
|
Result := (TimeoutMS >= MIN_TIMEOUT) and (TimeoutMS <= MAX_TIMEOUT);
|
|
end;
|
|
|
|
// ===========================================================================
|
|
// Platform transport
|
|
// ===========================================================================
|
|
|
|
{$IFDEF WINDOWS}
|
|
type
|
|
HINTERNET = Pointer;
|
|
|
|
const
|
|
winhttpdll = 'winhttp.dll';
|
|
|
|
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY = 0;
|
|
WINHTTP_FLAG_SECURE = $00800000;
|
|
|
|
WINHTTP_OPTION_REDIRECT_POLICY = 88;
|
|
WINHTTP_OPTION_REDIRECT_POLICY_NEVER = 0;
|
|
|
|
WINHTTP_QUERY_STATUS_CODE = 19;
|
|
WINHTTP_QUERY_FLAG_NUMBER = $20000000;
|
|
|
|
WINHTTP_ADDREQ_FLAG_ADD = $20000000;
|
|
WINHTTP_ADDREQ_FLAG_REPLACE = $80000000;
|
|
|
|
function WinHttpOpen(pszAgentW: PWideChar; dwAccessType: DWORD;
|
|
pszProxyW, pszProxyBypassW: PWideChar; dwFlags: DWORD): HINTERNET; stdcall; external winhttpdll;
|
|
function WinHttpConnect(hSession: HINTERNET; pswzServerName: PWideChar;
|
|
nServerPort: Word; dwReserved: DWORD): HINTERNET; stdcall; external winhttpdll;
|
|
function WinHttpOpenRequest(hConnect: HINTERNET; pwszVerb, pwszObjectName, pwszVersion,
|
|
pwszReferrer: PWideChar; ppwszAcceptTypes: Pointer; dwFlags: DWORD): HINTERNET; stdcall; external winhttpdll;
|
|
function WinHttpSetTimeouts(hInternet: HINTERNET;
|
|
nResolveTimeout, nConnectTimeout, nSendTimeout, nReceiveTimeout: integer): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpSetOption(hInternet: HINTERNET; dwOption: DWORD;
|
|
lpBuffer: Pointer; dwBufferLength: DWORD): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpAddRequestHeaders(hRequest: HINTERNET; pwszHeaders: PWideChar;
|
|
dwHeadersLength, dwModifiers: DWORD): BOOL; stdcall; external winhttpdll;
|
|
// dwContext is a DWORD_PTR (pointer-sized) in WinHTTP: 4 bytes on Win32, 8 on Win64.
|
|
// PtrUInt is exact for both, unlike DWORD which would truncate/misalign the Win64 stack.
|
|
function WinHttpSendRequest(hRequest: HINTERNET; pwszHeaders: PWideChar;
|
|
dwHeadersLength: DWORD; lpOptional: Pointer; dwOptionalLength, dwTotalLength: DWORD; dwContext: PtrUInt): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpReceiveResponse(hRequest: HINTERNET; lpReserved: Pointer): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpQueryHeaders(hRequest: HINTERNET; dwInfoLevel: DWORD; pwszName: PWideChar;
|
|
lpBuffer: Pointer; var lpdwBufferLength: DWORD; var lpdwIndex: DWORD): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpQueryDataAvailable(hRequest: HINTERNET; var lpdwNumberOfBytesAvailable: DWORD): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpReadData(hRequest: HINTERNET; lpBuffer: Pointer;
|
|
dwNumberOfBytesToRead: DWORD; var lpdwNumberOfBytesRead: DWORD): BOOL; stdcall; external winhttpdll;
|
|
function WinHttpCloseHandle(hInternet: HINTERNET): BOOL; stdcall; external winhttpdll;
|
|
|
|
function W(const S: UTF8String): UnicodeString;
|
|
begin
|
|
Result := UTF8Decode(S);
|
|
end;
|
|
|
|
function PostJSON(const URL: UTF8String; Headers: TStrings; const Body: UTF8String;
|
|
TimeoutMS: DWORD; out StatusCode: DWORD; out ResponseBody: UTF8String;
|
|
out ErrorText: UTF8String): Boolean;
|
|
var
|
|
p: TParsedURL;
|
|
hSession, hConnect, hRequest: HINTERNET;
|
|
t: integer;
|
|
flags, policy: DWORD;
|
|
wobj, whdr: UnicodeString;
|
|
i: integer;
|
|
code, sz, idx, avail, readN, bodyLen: DWORD;
|
|
bodyPtr: Pointer;
|
|
ok2xx: boolean;
|
|
chunk: array of Byte;
|
|
resp: UTF8String;
|
|
|
|
// Every failure path funnels through here so ErrorText is always redacted AND
|
|
// hard-capped to <= MAX_ERRTEXT bytes. Order matters: redact first (redaction can
|
|
// grow the text), then cap, so a secret can never survive at the tail past the cut.
|
|
procedure Fail(const msg: UTF8String);
|
|
begin
|
|
ErrorText := AIHttp_Redact(msg, Headers);
|
|
if Length(ErrorText) > MAX_ERRTEXT then
|
|
SetLength(ErrorText, MAX_ERRTEXT);
|
|
end;
|
|
|
|
begin
|
|
Result := False;
|
|
StatusCode := 0;
|
|
ResponseBody := '';
|
|
ErrorText := '';
|
|
hSession := nil; hConnect := nil; hRequest := nil;
|
|
resp := '';
|
|
try // outer: guarantees handle cleanup on every path
|
|
try // inner: converts exceptions into a redacted ErrorText
|
|
if not AIHttp_ParseURL(URL, p, ErrorText) then Exit; // ParseURL text carries no secrets
|
|
|
|
// Reject out-of-range timeouts outright rather than silently clamping.
|
|
if not AIHttp_ValidTimeout(TimeoutMS) then
|
|
begin
|
|
Fail('TimeoutMS out of range (' + IntToStr(MIN_TIMEOUT) + '..' + IntToStr(MAX_TIMEOUT) + ')');
|
|
Exit;
|
|
end;
|
|
t := integer(TimeoutMS);
|
|
|
|
// Validate every outbound header BEFORE any WinHTTP call: a CR/LF or bogus
|
|
// field-name could otherwise inject a second header line (e.g. a forged
|
|
// Authorization the redactor never sees). Fail closed; message names no value.
|
|
if Headers <> nil then
|
|
for i := 0 to Headers.Count - 1 do
|
|
begin
|
|
if Trim(Headers[i]) = '' then Continue;
|
|
if not AIHttp_ValidHeaderLine(Headers[i]) then
|
|
begin Fail('invalid header line at index ' + IntToStr(i) + ' (CR/LF or malformed field-name)'); Exit; end;
|
|
end;
|
|
|
|
// Request body pointer must be nil when empty; explicit first-byte pointer otherwise.
|
|
if Length(Body) > High(DWORD) then begin Fail('request body too large'); Exit; end;
|
|
bodyLen := DWORD(Length(Body));
|
|
if bodyLen = 0 then bodyPtr := nil else bodyPtr := @Body[1];
|
|
|
|
hSession := WinHttpOpen(PWideChar(UnicodeString('CheatEngine-AI/1.0')),
|
|
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, nil, nil, 0);
|
|
if hSession = nil then begin Fail('WinHttpOpen failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
// Timeouts are a security control (bound a slowloris peer); fail closed if unset.
|
|
if not WinHttpSetTimeouts(hSession, t, t, t, t) then
|
|
begin Fail('WinHttpSetTimeouts failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
hConnect := WinHttpConnect(hSession, PWideChar(W(p.Host)), p.Port, 0);
|
|
if hConnect = nil then begin Fail('WinHttpConnect failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
flags := 0;
|
|
if p.Secure then flags := WINHTTP_FLAG_SECURE;
|
|
wobj := W(p.Path);
|
|
hRequest := WinHttpOpenRequest(hConnect, PWideChar(UnicodeString('POST')),
|
|
PWideChar(wobj), nil, nil, nil, flags);
|
|
if hRequest = nil then begin Fail('WinHttpOpenRequest failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
// Never follow redirects: secret headers must not cross to another host. This is a
|
|
// hard security control, so a failure to set it must abort the request.
|
|
policy := WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
|
if not WinHttpSetOption(hRequest, WINHTTP_OPTION_REDIRECT_POLICY, @policy, SizeOf(policy)) then
|
|
begin Fail('WinHttpSetOption(redirect policy) failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
// NOTE: TLS certificate validation is left at WinHTTP defaults on purpose.
|
|
|
|
if Headers <> nil then
|
|
for i := 0 to Headers.Count - 1 do
|
|
begin
|
|
if Trim(Headers[i]) = '' then Continue; // already validated above, pre-network
|
|
whdr := W(Headers[i]);
|
|
// A dropped auth/api-key header would silently send an unauthenticated request;
|
|
// fail closed instead. Error text is redacted, so the header value never leaks.
|
|
if not WinHttpAddRequestHeaders(hRequest, PWideChar(whdr), DWORD(-1),
|
|
WINHTTP_ADDREQ_FLAG_ADD or WINHTTP_ADDREQ_FLAG_REPLACE) then
|
|
begin Fail('WinHttpAddRequestHeaders failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
end;
|
|
|
|
if not WinHttpSendRequest(hRequest, nil, 0,
|
|
bodyPtr, bodyLen, bodyLen, 0) then
|
|
begin Fail('WinHttpSendRequest failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
if not WinHttpReceiveResponse(hRequest, nil) then
|
|
begin Fail('WinHttpReceiveResponse failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
|
|
// status code (numeric)
|
|
code := 0; sz := SizeOf(code); idx := 0;
|
|
if not WinHttpQueryHeaders(hRequest,
|
|
WINHTTP_QUERY_STATUS_CODE or WINHTTP_QUERY_FLAG_NUMBER, nil, @code, sz, idx) then
|
|
begin Fail('WinHttpQueryHeaders(status) failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
StatusCode := code;
|
|
|
|
// read body with 8 MiB cap enforced before each append/allocation
|
|
repeat
|
|
avail := 0;
|
|
if not WinHttpQueryDataAvailable(hRequest, avail) then
|
|
begin Fail('WinHttpQueryDataAvailable failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
if avail = 0 then Break;
|
|
if AIHttp_CapExceeded(Length(resp), avail) then
|
|
begin
|
|
// Cap breach is a failure: never hand back a partial (possibly secret-bearing)
|
|
// body. Clear it; the bounded ErrorText is the only surfaced excerpt.
|
|
ResponseBody := '';
|
|
Fail('response exceeded 8 MiB cap');
|
|
Exit;
|
|
end;
|
|
SetLength(chunk, avail);
|
|
readN := 0;
|
|
if not WinHttpReadData(hRequest, @chunk[0], avail, readN) then
|
|
begin Fail('WinHttpReadData failed (err '+IntToStr(GetLastOSError)+')'); Exit; end;
|
|
if readN = 0 then Break;
|
|
i := Length(resp);
|
|
SetLength(resp, i + integer(readN));
|
|
Move(chunk[0], resp[i + 1], readN);
|
|
until False;
|
|
|
|
ok2xx := (code >= 200) and (code < 300);
|
|
if ok2xx then
|
|
begin
|
|
ResponseBody := resp;
|
|
Result := True;
|
|
end
|
|
else
|
|
begin
|
|
// Non-2xx bodies routinely echo the request's auth header (see providers' 401
|
|
// payloads). Simplest safe contract: never surface the failure body at all —
|
|
// clear ResponseBody, and carry only a bounded, redacted excerpt in ErrorText.
|
|
// Fail() redacts then hard-caps the whole string to MAX_ERRTEXT centrally, so
|
|
// the pre-bound here just limits the raw excerpt before redaction expands it.
|
|
ResponseBody := '';
|
|
Fail('HTTP ' + IntToStr(code) + '. ' + Copy(resp, 1, MAX_ERR_BODY));
|
|
Result := False;
|
|
end;
|
|
except
|
|
on E: Exception do
|
|
begin
|
|
Result := False;
|
|
Fail('exception: ' + E.Message);
|
|
end;
|
|
end;
|
|
// Handles ALWAYS close, on every early Exit and on exception.
|
|
finally
|
|
if hRequest <> nil then WinHttpCloseHandle(hRequest);
|
|
if hConnect <> nil then WinHttpCloseHandle(hConnect);
|
|
if hSession <> nil then WinHttpCloseHandle(hSession);
|
|
end;
|
|
end;
|
|
|
|
{$ELSE}
|
|
|
|
function PostJSON(const URL: UTF8String; Headers: TStrings; const Body: UTF8String;
|
|
TimeoutMS: DWORD; out StatusCode: DWORD; out ResponseBody: UTF8String;
|
|
out ErrorText: UTF8String): Boolean;
|
|
begin
|
|
StatusCode := 0;
|
|
ResponseBody := '';
|
|
ErrorText := 'HTTP transport is only supported on Windows (WinHTTP)';
|
|
Result := False;
|
|
end;
|
|
|
|
{$ENDIF}
|
|
|
|
end.
|