Skip to content

Commit 1420c10

Browse files
authored
Validate tarball paths on create (#183)
1 parent bcff989 commit 1420c10

3 files changed

Lines changed: 333 additions & 54 deletions

File tree

src/hex_core.erl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@
6666
%% * `tarball_max_uncompressed_size' - Maximum size of uncompressed package tarball, defaults to
6767
%% `134_217_728' (128 MiB). Set to `infinity' to not enforce the limit.
6868
%%
69+
%% * `tarball_files_root' - Root directory for source files when creating tarballs.
70+
%% Required for filesystem source paths, which must be relative and must resolve inside
71+
%% this root after following symlinks. Set to `undefined' when all tarball contents are
72+
%% provided as binaries and no filesystem source paths are used (default: `undefined').
73+
%%
6974
%% * `docs_tarball_max_size' - Maximum size of docs tarball, defaults to
7075
%% `16_777_216' (16 MiB). Set to `infinity' to not enforce the limit.
7176
%%
@@ -113,6 +118,7 @@
113118
repo_verify => boolean(),
114119
repo_verify_origin => boolean(),
115120
send_100_continue => boolean(),
121+
tarball_files_root => file:filename() | undefined,
116122
tarball_max_size => pos_integer() | infinity,
117123
tarball_max_uncompressed_size => pos_integer() | infinity,
118124
docs_tarball_max_size => pos_integer() | infinity,
@@ -140,6 +146,7 @@ default_config() ->
140146
repo_verify => true,
141147
repo_verify_origin => true,
142148
send_100_continue => true,
149+
tarball_files_root => undefined,
143150
tarball_max_size => 16 * 1024 * 1024,
144151
tarball_max_uncompressed_size => 128 * 1024 * 1024,
145152
docs_tarball_max_size => 16 * 1024 * 1024,

src/hex_tarball.erl

Lines changed: 197 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -67,42 +67,50 @@ create(Metadata, Files, Config) ->
6767
tarball_max_size := TarballMaxSize,
6868
tarball_max_uncompressed_size := TarballMaxUncompressedSize
6969
} = Config,
70+
FilesRoot = maps:get(tarball_files_root, Config, undefined),
7071

7172
MetadataBinary = encode_metadata(Metadata),
7273

7374
case valid_size(MetadataBinary, ?MAX_METADATA_SIZE) of
7475
false ->
7576
{error, {tarball, {file_too_big, "metadata.config"}}};
7677
true ->
77-
ContentsTarball = create_memory_tarball(Files),
78-
ContentsTarballCompressed = gzip(ContentsTarball),
79-
InnerChecksum = inner_checksum(?VERSION, MetadataBinary, ContentsTarballCompressed),
80-
InnerChecksumBase16 = encode_base16(InnerChecksum),
81-
82-
OuterFiles = [
83-
{"VERSION", ?VERSION},
84-
{"CHECKSUM", InnerChecksumBase16},
85-
{"metadata.config", MetadataBinary},
86-
{"contents.tar.gz", ContentsTarballCompressed}
87-
],
88-
89-
case valid_size(ContentsTarball, TarballMaxUncompressedSize) of
90-
true ->
91-
Tarball = create_memory_tarball(OuterFiles),
92-
OuterChecksum = checksum(Tarball),
78+
case validate_create_files(Files, FilesRoot) of
79+
{ok, ValidatedFiles} ->
80+
ContentsTarball = create_memory_tarball(ValidatedFiles),
81+
ContentsTarballCompressed = gzip(ContentsTarball),
82+
InnerChecksum = inner_checksum(
83+
?VERSION, MetadataBinary, ContentsTarballCompressed
84+
),
85+
InnerChecksumBase16 = encode_base16(InnerChecksum),
86+
87+
OuterFiles = [
88+
{"VERSION", ?VERSION},
89+
{"CHECKSUM", InnerChecksumBase16},
90+
{"metadata.config", MetadataBinary},
91+
{"contents.tar.gz", ContentsTarballCompressed}
92+
],
9393

94-
case valid_size(Tarball, TarballMaxSize) of
94+
case valid_size(ContentsTarball, TarballMaxUncompressedSize) of
9595
true ->
96-
{ok, #{
97-
tarball => Tarball,
98-
outer_checksum => OuterChecksum,
99-
inner_checksum => InnerChecksum
100-
}};
96+
Tarball = create_memory_tarball(OuterFiles),
97+
OuterChecksum = checksum(Tarball),
98+
99+
case valid_size(Tarball, TarballMaxSize) of
100+
true ->
101+
{ok, #{
102+
tarball => Tarball,
103+
outer_checksum => OuterChecksum,
104+
inner_checksum => InnerChecksum
105+
}};
106+
false ->
107+
{error, {tarball, {too_big_compressed, TarballMaxSize}}}
108+
end;
101109
false ->
102-
{error, {tarball, {too_big_compressed, TarballMaxSize}}}
110+
{error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
103111
end;
104-
false ->
105-
{error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
112+
{error, _} = Error ->
113+
Error
106114
end
107115
end.
108116

@@ -133,21 +141,27 @@ create_docs(Files, Config) ->
133141
docs_tarball_max_size := TarballMaxSize,
134142
docs_tarball_max_uncompressed_size := TarballMaxUncompressedSize
135143
} = Config,
144+
FilesRoot = maps:get(tarball_files_root, Config, undefined),
136145

137-
UncompressedTarball = create_memory_tarball(Files),
146+
case validate_create_files(Files, FilesRoot) of
147+
{ok, ValidatedFiles} ->
148+
UncompressedTarball = create_memory_tarball(ValidatedFiles),
138149

139-
case valid_size(UncompressedTarball, TarballMaxUncompressedSize) of
140-
true ->
141-
Tarball = gzip(UncompressedTarball),
142-
143-
case valid_size(Tarball, TarballMaxSize) of
150+
case valid_size(UncompressedTarball, TarballMaxUncompressedSize) of
144151
true ->
145-
{ok, Tarball};
152+
Tarball = gzip(UncompressedTarball),
153+
154+
case valid_size(Tarball, TarballMaxSize) of
155+
true ->
156+
{ok, Tarball};
157+
false ->
158+
{error, {tarball, {too_big_compressed, TarballMaxSize}}}
159+
end;
146160
false ->
147-
{error, {tarball, {too_big_compressed, TarballMaxSize}}}
161+
{error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
148162
end;
149-
false ->
150-
{error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
163+
{error, _} = Error ->
164+
Error
151165
end.
152166

153167
-spec create_docs(files()) -> {ok, tarball()} | {error, term()}.
@@ -340,10 +354,18 @@ format_error({tarball, {too_big_compressed, Size}}) ->
340354
io_lib:format("package exceeds max compressed size ~w ~s", [format_byte_size(Size), "MB"]);
341355
format_error({tarball, {missing_files, Files}}) ->
342356
io_lib:format("missing files: ~p", [Files]);
357+
format_error({tarball, missing_files_root}) ->
358+
"tarball files root is required when creating tarballs from filesystem paths";
343359
format_error({tarball, {bad_version, Vsn}}) ->
344360
io_lib:format("unsupported version: ~p", [Vsn]);
345361
format_error({tarball, invalid_checksum}) ->
346362
"invalid tarball checksum";
363+
format_error({tarball, {unsafe_path, Name}}) ->
364+
io_lib:format("unsafe path in tarball: ~s", [Name]);
365+
format_error({tarball, {unsafe_symlink, Name, LinkTarget}}) ->
366+
io_lib:format("unsafe symlink in tarball: ~s -> ~s", [Name, LinkTarget]);
367+
format_error({tarball, {unsupported_file_type, Name, Type}}) ->
368+
io_lib:format("unsupported file type in tarball: ~s (~p)", [Name, Type]);
347369
format_error({tarball, Reason}) ->
348370
"tarball error, " ++ hex_erl_tar:format_error(Reason);
349371
format_error({inner_tarball, Reason}) ->
@@ -881,6 +903,146 @@ guess_build_tools(Metadata) ->
881903
%% Tar Helpers
882904
%%====================================================================
883905

906+
%% @private
907+
validate_create_files(Files, FilesRoot) when is_list(Files) ->
908+
validate_create_files(Files, FilesRoot, []).
909+
910+
validate_create_files([], _FilesRoot, Acc) ->
911+
{ok, lists:reverse(Acc)};
912+
validate_create_files([File | Rest], FilesRoot, Acc) ->
913+
case validate_create_file(File, FilesRoot) of
914+
{ok, ValidatedFile} -> validate_create_files(Rest, FilesRoot, [ValidatedFile | Acc]);
915+
{error, _} = Error -> Error
916+
end.
917+
918+
validate_create_file({Filename, Contents}, _FilesRoot) when
919+
is_list(Filename), is_binary(Contents)
920+
->
921+
case validate_archive_path(Filename) of
922+
ok -> {ok, {Filename, Contents}};
923+
{error, _} = Error -> Error
924+
end;
925+
validate_create_file(Filename, FilesRoot) when is_list(Filename) ->
926+
validate_create_file({Filename, Filename}, FilesRoot);
927+
validate_create_file({Filename, AbsFilename}, FilesRoot) when
928+
is_list(Filename), is_list(AbsFilename)
929+
->
930+
case validate_archive_path(Filename) of
931+
ok -> validate_source_file(Filename, AbsFilename, FilesRoot);
932+
{error, _} = Error -> Error
933+
end.
934+
935+
validate_archive_path(Filename) ->
936+
case safe_relative_archive_path(Filename) of
937+
false -> {error, {tarball, {unsafe_path, Filename}}};
938+
true -> ok
939+
end.
940+
941+
validate_source_file(ArchiveName, SourcePath, FilesRoot) ->
942+
case validate_source_path(SourcePath) of
943+
ok -> validate_source_file_root(ArchiveName, SourcePath, FilesRoot);
944+
{error, _} = Error -> Error
945+
end.
946+
947+
validate_source_path(SourcePath) ->
948+
case safe_relative_archive_path(SourcePath) of
949+
false -> {error, {tarball, {unsafe_path, SourcePath}}};
950+
true -> ok
951+
end.
952+
953+
validate_source_file_root(_ArchiveName, _SourcePath, undefined) ->
954+
{error, {tarball, missing_files_root}};
955+
validate_source_file_root(ArchiveName, SourcePath, FilesRoot) ->
956+
Root = filename:absname(FilesRoot),
957+
DiskPath = filename:join(Root, SourcePath),
958+
case file:read_link_info(DiskPath, []) of
959+
{ok, #file_info{type = Type}} when Type =:= regular; Type =:= directory ->
960+
case validate_source_root(ArchiveName, SourcePath, Root) of
961+
ok -> {ok, {ArchiveName, DiskPath}};
962+
{error, _} = Error -> Error
963+
end;
964+
{ok, #file_info{type = symlink}} ->
965+
{ok, LinkTarget} = file:read_link(DiskPath),
966+
ResolvedTarget = archive_join(archive_dirname(ArchiveName), LinkTarget),
967+
case safe_relative_archive_path(ResolvedTarget) of
968+
false ->
969+
{error, {tarball, {unsafe_symlink, ArchiveName, LinkTarget}}};
970+
true ->
971+
case validate_source_root(ArchiveName, SourcePath, Root) of
972+
ok -> {ok, {ArchiveName, DiskPath}};
973+
{error, _} = Error -> Error
974+
end
975+
end;
976+
{ok, #file_info{type = Type}} ->
977+
{error, {tarball, {unsupported_file_type, ArchiveName, Type}}};
978+
_ ->
979+
{ok, {ArchiveName, DiskPath}}
980+
end.
981+
982+
validate_source_root(ArchiveName, SourcePath, FilesRoot) ->
983+
case filelib:safe_relative_path(SourcePath, FilesRoot) of
984+
unsafe -> {error, {tarball, {unsafe_path, ArchiveName}}};
985+
_ -> ok
986+
end.
987+
988+
safe_relative_archive_path(Path) ->
989+
case archive_path_absolute(Path) orelse archive_path_drive(Path) of
990+
true -> false;
991+
false -> safe_relative_archive_path(archive_path_split(Path), [])
992+
end.
993+
994+
safe_relative_archive_path([], _Acc) ->
995+
true;
996+
safe_relative_archive_path(["." | Rest], Acc) ->
997+
safe_relative_archive_path(Rest, Acc);
998+
safe_relative_archive_path([".." | _Rest], []) ->
999+
false;
1000+
safe_relative_archive_path([".." | Rest], [_ | Acc]) ->
1001+
safe_relative_archive_path(Rest, Acc);
1002+
safe_relative_archive_path([_Part | Rest], Acc) ->
1003+
safe_relative_archive_path(Rest, [ok | Acc]).
1004+
1005+
archive_path_absolute([$/ | _Rest]) ->
1006+
true;
1007+
archive_path_absolute([$\\ | _Rest]) ->
1008+
true;
1009+
archive_path_absolute(_Path) ->
1010+
false.
1011+
1012+
archive_path_drive([Drive, $: | _Rest]) when
1013+
Drive >= $a, Drive =< $z;
1014+
Drive >= $A, Drive =< $Z
1015+
->
1016+
true;
1017+
archive_path_drive(_Path) ->
1018+
false.
1019+
1020+
archive_path_split(Path) ->
1021+
string:tokens(Path, "/\\").
1022+
1023+
archive_dirname(Path) ->
1024+
case archive_path_split(Path) of
1025+
[] -> ".";
1026+
[_Name] -> ".";
1027+
Parts -> string:join(lists:droplast(Parts), "/")
1028+
end.
1029+
1030+
archive_join(_Dir, Path) when Path =:= [] ->
1031+
Path;
1032+
archive_join(_Dir, Path = [$/ | _Rest]) ->
1033+
Path;
1034+
archive_join(_Dir, Path = [$\\ | _Rest]) ->
1035+
Path;
1036+
archive_join(_Dir, Path = [Drive, $: | _Rest]) when
1037+
Drive >= $a, Drive =< $z;
1038+
Drive >= $A, Drive =< $Z
1039+
->
1040+
Path;
1041+
archive_join(".", Path) ->
1042+
Path;
1043+
archive_join(Dir, Path) ->
1044+
Dir ++ "/" ++ Path.
1045+
8841046
%% @private
8851047
unpack_tarball(Source, memory, MaxSize) ->
8861048
case hex_erl_tar:extract(Source, [memory, compressed, {max_size, MaxSize}]) of

0 commit comments

Comments
 (0)