Files
fpc-msgbase/src/mb.paths.pas
Ken Johnson 180ca954f7 Harden parsers against crafted message-base files
Audit pass targeting attacker-controlled binary inputs (.JHR, .SQD,
*.MSG, Wildcat ISAM) and shared-directory sentinels.  Caps bound
allocations driven by in-file length fields; a few forward-only
invariants bound chain walks; O_NOFOLLOW plugs a lock-file
symlink-swap window.

JAM  (mb.fmt.jam): cap SubfieldLen (ReadSubFields) and TxtLen
(ReadBody) at 64 MiB.  Reject negative TxtOffset before seek.

Squish  (mb.fmt.squish): reject clen/msg_length outside the frame,
and clen > msg_length - SizeOf(SqMsgHdr) (prevents bodyLen
underflow into a negative SetLength argument).  ReIndex now
rejects non-forward next_frame and caps total hops.

Wildcat  (mb.fmt.wildcat): clamp Hdr.MsgBytes to SizeOf(TMsgText)
in ReadBody so Move() cannot read past the fixed-size buffer.

*.MSG  (mb.fmt.msg): cap ReadBody growth at 16 MiB; pre-size the
AnsiString so concatenation is O(n), not O(n^2).

mb.kludge: pre-count CRs so SplitKludgeBlob is O(n) instead of
O(n^2), and cap the parsed line count at 10 K.

mb.lock: POSIX fpOpen now passes O_NOFOLLOW so a symlink that an
attacker drops in place of the sentinel between FileExists and
fpOpen does not redirect us.  Advisory flock semantics unchanged.

mb.paths: new IsSafePathComponent / EnforceSafePathComponent
helpers.  Reject empty, '.', '..', absolute, drive-prefixed, or
separator-bearing tails; used by callers that accept area tags or
filenames from outside data.
2026-04-19 06:27:46 -07:00

251 lines
8.5 KiB
ObjectPascal

{ mb.paths - per-backend filename derivation and
case-insensitive existence resolution (tries path, UPPER, lower
before giving up). }
unit mb.paths;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils,
mb.types;
{ Directory/basename joining that always uses PathDelim. }
function PathJoin(const ADir, ATail: AnsiString): AnsiString;
function PathAppendSep(const ADir: AnsiString): AnsiString;
{ True when ATail is safe to use as a single path component below a
trusted base directory: non-empty, no NUL, no directory separators,
no '..', not an absolute path, not a drive-letter prefix.
The caller is the trust boundary; once a name has been validated it
is safe to feed into PathJoin. }
function IsSafePathComponent(const ATail: AnsiString): boolean;
{ Raise EInOutError when ATail is not a safe component. Used on
entry points that accept an area tag or filename derived from
outside data (.pkt filenames, flow-file lines, config values). }
procedure EnforceSafePathComponent(const ATail: AnsiString;
const AWhere: AnsiString = '');
{ Try APath as-is, then UpperCase(basename), then LowerCase(basename).
Returns the first that exists, or APath unchanged when nothing
resolves (so the caller can produce a useful error message). }
function FindExistingFile(const APath: AnsiString): AnsiString;
{ Produce the canonical constructor argument each backend expects.
Format AAreaPath AAreaTag Returns
-------------- ----------------- ---------------- ----------------
mbfHudson <dir> ignored <dir>
mbfGoldBase <dir> ignored <dir>
mbfJam <dir> <tag> <dir>/<tag>
mbfSquish <dir> <tag> <dir>/<tag>
mbfPCBoard <dir> <basename> <dir>/<basename>
mbfMsg <dir> ignored <dir>
mbfPkt <dir> <filename> <dir>/<filename>
mbfEzyCom <dir> <boardNumStr> <dir> (+ AAreaTag)
mbfWildcat <wcroot> <confNumStr> <wcroot> (+ AAreaTag)
}
function MessageBasePathFor(AFormat: TMsgBaseFormat;
const AAreaPath: AnsiString;
const AAreaTag: AnsiString = ''): AnsiString;
{ Compose the per-file name a backend reads/writes. E.g.
HudsonFilePath(dir, 'MSGINFO.BBS') -> <dir>/MSGINFO.BBS, with
case-insensitive resolution. Callers use this internally so
DOS-era mixed-case file layouts keep working on case-sensitive
Unix filesystems. }
function HudsonFilePath(const ADir, ABaseName: AnsiString): AnsiString;
function GoldBaseFilePath(const ADir, ABaseName: AnsiString): AnsiString;
function JamFilePath(const ABaseName, AExt: AnsiString): AnsiString;
function SquishFilePath(const ABaseName, AExt: AnsiString): AnsiString;
function PCBoardFilePath(const ABaseName, AExt: AnsiString): AnsiString;
function MsgDirFile(const ADir: AnsiString; AMsgNum: longint): AnsiString;
function EzyComFilePath(const ADir: AnsiString;
ABoard: word;
const APrefix, AExt: AnsiString): AnsiString;
{ Derived sentinel file name for mb.lock. Backends that share a
file layout should agree on the same sentinel so cross-process
locks work even with other tools in the ecosystem (e.g. Squish
uses .SQL per the format spec). }
function LockFilePath(AFormat: TMsgBaseFormat;
const ABasePath: AnsiString;
const AAreaTag: AnsiString = ''): AnsiString;
implementation
function PathAppendSep(const ADir: AnsiString): AnsiString;
begin
if ADir = '' then
Result := ''
else if ADir[Length(ADir)] = PathDelim then
Result := ADir
else
Result := ADir + PathDelim;
end;
function PathJoin(const ADir, ATail: AnsiString): AnsiString;
begin
if ATail = '' then Result := ADir
else if ADir = '' then Result := ATail
else Result := PathAppendSep(ADir) + ATail;
end;
function IsSafePathComponent(const ATail: AnsiString): boolean;
var
i: integer;
c: AnsiChar;
begin
Result := False;
if ATail = '' then exit;
if (ATail = '.') or (ATail = '..') then exit;
{ Block absolute paths and Windows drive-letter prefixes. }
if ATail[1] in ['/', '\'] then exit;
if (Length(ATail) >= 2) and (ATail[2] = ':') then exit;
for i := 1 to Length(ATail) do begin
c := ATail[i];
if (c = #0) or (c = '/') or (c = '\') then exit;
end;
{ Reject any occurrence of '..' as a sub-component; after the
separator check above we also know there are no separators, so
a literal '..' substring can only mean dot-dot traversal in a
tail that was hand-built by concatenation. }
if Pos('..', ATail) > 0 then exit;
Result := True;
end;
procedure EnforceSafePathComponent(const ATail: AnsiString;
const AWhere: AnsiString);
begin
if not IsSafePathComponent(ATail) then begin
if AWhere = '' then
raise EInOutError.CreateFmt(
'mb.paths: unsafe path component %s', [AnsiQuotedStr(ATail, '"')])
else
raise EInOutError.CreateFmt(
'mb.paths: unsafe path component %s in %s',
[AnsiQuotedStr(ATail, '"'), AWhere]);
end;
end;
function FindExistingFile(const APath: AnsiString): AnsiString;
var
dir, name, upper, lower: AnsiString;
begin
Result := APath;
if APath = '' then exit;
if FileExists(APath) then exit;
dir := ExtractFilePath(APath);
name := ExtractFileName(APath);
if name = '' then exit;
upper := dir + UpperCase(name);
if FileExists(upper) then begin Result := upper; exit; end;
lower := dir + LowerCase(name);
if FileExists(lower) then begin Result := lower; exit; end;
end;
function MessageBasePathFor(AFormat: TMsgBaseFormat;
const AAreaPath: AnsiString;
const AAreaTag: AnsiString): AnsiString;
begin
case AFormat of
mbfHudson, mbfGoldBase, mbfMsg:
Result := PathAppendSep(AAreaPath);
mbfJam, mbfSquish, mbfPCBoard, mbfPkt:
{ mbfPkt's path resolution stays here -- the enum value is
defined in mb.types so the factory can register from
fpc-ftn-transport, and the .pkt is a single file (same
shape as JAM/Squish/PCBoard). }
if AAreaTag = '' then
Result := AAreaPath
else
Result := PathJoin(AAreaPath, AAreaTag);
mbfEzyCom, mbfWildcat:
Result := PathAppendSep(AAreaPath);
else
Result := AAreaPath;
end;
end;
function HudsonFilePath(const ADir, ABaseName: AnsiString): AnsiString;
begin
Result := FindExistingFile(PathJoin(ADir, ABaseName));
end;
function GoldBaseFilePath(const ADir, ABaseName: AnsiString): AnsiString;
begin
Result := FindExistingFile(PathJoin(ADir, ABaseName));
end;
function JamFilePath(const ABaseName, AExt: AnsiString): AnsiString;
begin
Result := FindExistingFile(ABaseName + AExt);
end;
function SquishFilePath(const ABaseName, AExt: AnsiString): AnsiString;
begin
Result := FindExistingFile(ABaseName + AExt);
end;
function PCBoardFilePath(const ABaseName, AExt: AnsiString): AnsiString;
begin
Result := FindExistingFile(ABaseName + AExt);
end;
function MsgDirFile(const ADir: AnsiString; AMsgNum: longint): AnsiString;
begin
Result := FindExistingFile(PathJoin(ADir,
Format('%d.msg', [AMsgNum])));
end;
function EzyComFilePath(const ADir: AnsiString;
ABoard: word;
const APrefix, AExt: AnsiString): AnsiString;
var
fn: AnsiString;
begin
{ EzyCom on-disk naming: AREAn/MH#####.BBS }
fn := Format('AREA%d%s%s%.5d%s',
[ABoard, PathDelim, APrefix, ABoard, AExt]);
Result := FindExistingFile(PathJoin(ADir, fn));
end;
function LockFilePath(AFormat: TMsgBaseFormat;
const ABasePath: AnsiString;
const AAreaTag: AnsiString): AnsiString;
begin
case AFormat of
mbfHudson, mbfGoldBase:
Result := PathJoin(ABasePath, 'msgbase.lck');
mbfJam:
Result := ABasePath + '.lck';
mbfSquish:
{ Squish's native lock is .SQL -- share it so other Squish
tools notice us (and we notice them). }
Result := ABasePath + '.sql';
mbfPCBoard:
Result := ABasePath + '.lck';
mbfMsg:
Result := PathJoin(ABasePath, 'msgdir.lck');
mbfPkt:
Result := ABasePath + '.lck';
mbfEzyCom:
Result := PathJoin(ABasePath, 'ezycom.lck');
mbfWildcat:
Result := PathJoin(ABasePath, 'wildcat.lck');
else
Result := ABasePath + '.lck';
end;
if AAreaTag <> '' then ; { reserved for future per-area sentinels }
end;
end.