Skip to content

Commit 64b9f92

Browse files
committed
Sync root-relative tarball source validation
1 parent e3a91b4 commit 64b9f92

3 files changed

Lines changed: 129 additions & 34 deletions

File tree

src/mix_hex_core.erl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@
6868
%% * `tarball_max_uncompressed_size' - Maximum size of uncompressed package tarball, defaults to
6969
%% `134_217_728' (128 MiB). Set to `infinity' to not enforce the limit.
7070
%%
71-
%% * `tarball_files_root' - Root directory source files must resolve inside when creating tarballs.
72-
%% Set to `undefined' to skip source root validation (default: `undefined').
71+
%% * `tarball_files_root' - Root directory for source files when creating tarballs.
72+
%% Required for filesystem source paths, which must be relative and must resolve inside
73+
%% this root after following symlinks. Set to `undefined' when all tarball contents are
74+
%% provided as binaries and no filesystem source paths are used (default: `undefined').
7375
%%
7476
%% * `docs_tarball_max_size' - Maximum size of docs tarball, defaults to
7577
%% `16_777_216' (16 MiB). Set to `infinity' to not enforce the limit.

src/mix_hex_tarball.erl

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ create(Metadata, Files, Config) ->
7777
{error, {tarball, {file_too_big, "metadata.config"}}};
7878
true ->
7979
case validate_create_files(Files, FilesRoot) of
80-
ok ->
81-
ContentsTarball = create_memory_tarball(Files),
80+
{ok, ValidatedFiles} ->
81+
ContentsTarball = create_memory_tarball(ValidatedFiles),
8282
ContentsTarballCompressed = gzip(ContentsTarball),
8383
InnerChecksum = inner_checksum(?VERSION, MetadataBinary, ContentsTarballCompressed),
8484
InnerChecksumBase16 = encode_base16(InnerChecksum),
@@ -143,8 +143,8 @@ create_docs(Files, Config) ->
143143
FilesRoot = maps:get(tarball_files_root, Config, undefined),
144144

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

149149
case valid_size(UncompressedTarball, TarballMaxUncompressedSize) of
150150
true ->
@@ -353,6 +353,8 @@ format_error({tarball, {too_big_compressed, Size}}) ->
353353
io_lib:format("package exceeds max compressed size ~w ~s", [format_byte_size(Size), "MB"]);
354354
format_error({tarball, {missing_files, Files}}) ->
355355
io_lib:format("missing files: ~p", [Files]);
356+
format_error({tarball, missing_files_root}) ->
357+
"tarball files root is required when creating tarballs from filesystem paths";
356358
format_error({tarball, {bad_version, Vsn}}) ->
357359
io_lib:format("unsupported version: ~p", [Vsn]);
358360
format_error({tarball, invalid_checksum}) ->
@@ -651,17 +653,21 @@ guess_build_tools(Metadata) ->
651653

652654
%% @private
653655
validate_create_files(Files, FilesRoot) when is_list(Files) ->
654-
validate_create_files(Files, FilesRoot, ok).
656+
validate_create_files(Files, FilesRoot, []).
655657

656-
validate_create_files(_Files, _FilesRoot, {error, _} = Error) ->
657-
Error;
658-
validate_create_files([], _FilesRoot, ok) ->
659-
ok;
660-
validate_create_files([File | Rest], FilesRoot, ok) ->
661-
validate_create_files(Rest, FilesRoot, validate_create_file(File, FilesRoot)).
658+
validate_create_files([], _FilesRoot, Acc) ->
659+
{ok, lists:reverse(Acc)};
660+
validate_create_files([File | Rest], FilesRoot, Acc) ->
661+
case validate_create_file(File, FilesRoot) of
662+
{ok, ValidatedFile} -> validate_create_files(Rest, FilesRoot, [ValidatedFile | Acc]);
663+
{error, _} = Error -> Error
664+
end.
662665

663666
validate_create_file({Filename, Contents}, _FilesRoot) when is_list(Filename), is_binary(Contents) ->
664-
validate_archive_path(Filename);
667+
case validate_archive_path(Filename) of
668+
ok -> {ok, {Filename, Contents}};
669+
{error, _} = Error -> Error
670+
end;
665671
validate_create_file(Filename, FilesRoot) when is_list(Filename) ->
666672
validate_create_file({Filename, Filename}, FilesRoot);
667673
validate_create_file({Filename, AbsFilename}, FilesRoot) when is_list(Filename), is_list(AbsFilename) ->
@@ -677,26 +683,47 @@ validate_archive_path(Filename) ->
677683
end.
678684

679685
validate_source_file(ArchiveName, SourcePath, FilesRoot) ->
680-
case file:read_link_info(SourcePath, []) of
686+
case validate_source_path(SourcePath) of
687+
ok -> validate_source_file_root(ArchiveName, SourcePath, FilesRoot);
688+
{error, _} = Error -> Error
689+
end.
690+
691+
validate_source_path(SourcePath) ->
692+
case safe_relative_archive_path(SourcePath) of
693+
false -> {error, {tarball, {unsafe_path, SourcePath}}};
694+
true -> ok
695+
end.
696+
697+
validate_source_file_root(_ArchiveName, _SourcePath, undefined) ->
698+
{error, {tarball, missing_files_root}};
699+
validate_source_file_root(ArchiveName, SourcePath, FilesRoot) ->
700+
Root = filename:absname(FilesRoot),
701+
DiskPath = filename:join(Root, SourcePath),
702+
case file:read_link_info(DiskPath, []) of
681703
{ok, #file_info{type = Type}} when Type =:= regular; Type =:= directory ->
682-
validate_source_root(ArchiveName, SourcePath, FilesRoot);
704+
case validate_source_root(ArchiveName, SourcePath, Root) of
705+
ok -> {ok, {ArchiveName, DiskPath}};
706+
{error, _} = Error -> Error
707+
end;
683708
{ok, #file_info{type = symlink}} ->
684-
{ok, LinkTarget} = file:read_link(SourcePath),
709+
{ok, LinkTarget} = file:read_link(DiskPath),
685710
ResolvedTarget = archive_join(archive_dirname(ArchiveName), LinkTarget),
686711
case safe_relative_archive_path(ResolvedTarget) of
687712
false -> {error, {tarball, {unsafe_symlink, ArchiveName, LinkTarget}}};
688-
true -> ok
713+
true ->
714+
case validate_source_root(ArchiveName, SourcePath, Root) of
715+
ok -> {ok, {ArchiveName, DiskPath}};
716+
{error, _} = Error -> Error
717+
end
689718
end;
690719
{ok, #file_info{type = Type}} ->
691720
{error, {tarball, {unsupported_file_type, ArchiveName, Type}}};
692721
_ ->
693-
ok
722+
{ok, {ArchiveName, DiskPath}}
694723
end.
695724

696-
validate_source_root(_ArchiveName, _SourcePath, undefined) ->
697-
ok;
698725
validate_source_root(ArchiveName, SourcePath, FilesRoot) ->
699-
case filelib:safe_relative_path(SourcePath, filename:absname(FilesRoot)) of
726+
case filelib:safe_relative_path(SourcePath, FilesRoot) of
700727
unsafe -> {error, {tarball, {unsafe_path, ArchiveName}}};
701728
_ -> ok
702729
end.

test/hex/tarball_test.exs

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,53 @@ defmodule Hex.TarballTest do
1717
:mix_hex_tarball.create_docs([{~c"/README.md", "README"}])
1818
end
1919

20+
test "create requires root-relative filesystem source paths" do
21+
metadata = %{name: "foo", version: "1.0.0"}
22+
23+
in_tmp(fn ->
24+
File.write!("README.md", "README")
25+
26+
root = File.cwd!() |> String.to_charlist()
27+
config = :mix_hex_core.default_config() |> Map.put(:tarball_files_root, root)
28+
absolute_readme = Path.expand("README.md") |> String.to_charlist()
29+
30+
assert {:error, {:tarball, :missing_files_root}} =
31+
:mix_hex_tarball.create(metadata, [{~c"README.md", ~c"README.md"}])
32+
33+
assert {:error, {:tarball, :missing_files_root}} =
34+
:mix_hex_tarball.create_docs([{~c"README.md", ~c"README.md"}])
35+
36+
assert {:error, {:tarball, {:unsafe_path, ^absolute_readme}}} =
37+
:mix_hex_tarball.create(metadata, [{~c"README.md", absolute_readme}], config)
38+
39+
assert {:error, {:tarball, {:unsafe_path, ^absolute_readme}}} =
40+
:mix_hex_tarball.create_docs([{~c"README.md", absolute_readme}], config)
41+
42+
assert {:ok, _} =
43+
:mix_hex_tarball.create(metadata, [{~c"README.md", ~c"README.md"}], config)
44+
45+
assert {:ok, _} =
46+
:mix_hex_tarball.create_docs([{~c"README.md", ~c"README.md"}], config)
47+
48+
File.mkdir!("../outside")
49+
File.write!("../outside/secret.txt", "outside")
50+
File.ln_s!("../outside/secret.txt", "mismatch_link")
51+
52+
assert {:error, {:tarball, {:unsafe_path, ~c"nested/mismatch_link"}}} =
53+
:mix_hex_tarball.create(
54+
metadata,
55+
[{~c"nested/mismatch_link", ~c"mismatch_link"}],
56+
config
57+
)
58+
59+
assert {:error, {:tarball, {:unsafe_path, ~c"nested/mismatch_link"}}} =
60+
:mix_hex_tarball.create_docs(
61+
[{~c"nested/mismatch_link", ~c"mismatch_link"}],
62+
config
63+
)
64+
end)
65+
end
66+
2067
test "create rejects escaping symlinks and accepts internal symlinks" do
2168
metadata = %{name: "foo", version: "1.0.0"}
2269

@@ -26,23 +73,38 @@ defmodule Hex.TarballTest do
2673
File.ln_s!("../README.md", "dir/internal")
2774
File.ln_s!("../../README.md", "dir/escaping")
2875

76+
config =
77+
:mix_hex_core.default_config()
78+
|> Map.put(:tarball_files_root, File.cwd!() |> String.to_charlist())
79+
2980
assert {:ok, _} =
30-
:mix_hex_tarball.create(metadata, [
31-
{~c"README.md", "README"},
32-
{~c"dir/internal", ~c"dir/internal"}
33-
])
81+
:mix_hex_tarball.create(
82+
metadata,
83+
[
84+
{~c"README.md", "README"},
85+
{~c"dir/internal", ~c"dir/internal"}
86+
],
87+
config
88+
)
3489

3590
assert {:error, {:tarball, {:unsafe_symlink, ~c"dir/escaping", ~c"../../README.md"}}} =
36-
:mix_hex_tarball.create(metadata, [{~c"dir/escaping", ~c"dir/escaping"}])
91+
:mix_hex_tarball.create(
92+
metadata,
93+
[{~c"dir/escaping", ~c"dir/escaping"}],
94+
config
95+
)
3796

3897
assert {:ok, _} =
39-
:mix_hex_tarball.create_docs([
40-
{~c"README.md", "README"},
41-
{~c"dir/internal", ~c"dir/internal"}
42-
])
98+
:mix_hex_tarball.create_docs(
99+
[
100+
{~c"README.md", "README"},
101+
{~c"dir/internal", ~c"dir/internal"}
102+
],
103+
config
104+
)
43105

44106
assert {:error, {:tarball, {:unsafe_symlink, ~c"dir/escaping", ~c"../../README.md"}}} =
45-
:mix_hex_tarball.create_docs([{~c"dir/escaping", ~c"dir/escaping"}])
107+
:mix_hex_tarball.create_docs([{~c"dir/escaping", ~c"dir/escaping"}], config)
46108
end)
47109
end
48110

@@ -53,11 +115,15 @@ defmodule Hex.TarballTest do
53115
in_tmp(fn ->
54116
System.cmd(mkfifo, ["fifo"])
55117

118+
config =
119+
:mix_hex_core.default_config()
120+
|> Map.put(:tarball_files_root, File.cwd!() |> String.to_charlist())
121+
56122
assert {:error, {:tarball, {:unsupported_file_type, ~c"fifo", :other}}} =
57-
:mix_hex_tarball.create(metadata, [{~c"fifo", ~c"fifo"}])
123+
:mix_hex_tarball.create(metadata, [{~c"fifo", ~c"fifo"}], config)
58124

59125
assert {:error, {:tarball, {:unsupported_file_type, ~c"fifo", :other}}} =
60-
:mix_hex_tarball.create_docs([{~c"fifo", ~c"fifo"}])
126+
:mix_hex_tarball.create_docs([{~c"fifo", ~c"fifo"}], config)
61127
end)
62128
end
63129
end

0 commit comments

Comments
 (0)