mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): relay notifications as encrypted web push (#2906)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(push_endpoint_guard_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(ENDPOINT, <<"https://push.example.com/wpush/v2/abc">>).
|
||||
|
||||
resolves_to(Addresses) ->
|
||||
fun(_Host) -> {ok, Addresses} end.
|
||||
|
||||
fails_with(Reason) ->
|
||||
fun(_Host) -> {error, Reason} end.
|
||||
|
||||
link_local_metadata_address_is_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([{169, 254, 169, 254}]))
|
||||
).
|
||||
|
||||
private_address_is_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([{10, 0, 0, 1}]))
|
||||
).
|
||||
|
||||
loopback_address_is_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([{127, 0, 0, 1}]))
|
||||
).
|
||||
|
||||
every_reserved_ipv4_range_is_refused_test() ->
|
||||
Blocked = [
|
||||
{0, 0, 0, 1},
|
||||
{10, 1, 2, 3},
|
||||
{100, 64, 0, 1},
|
||||
{127, 0, 0, 1},
|
||||
{169, 254, 169, 254},
|
||||
{172, 16, 0, 1},
|
||||
{172, 31, 255, 254},
|
||||
{192, 0, 0, 1},
|
||||
{192, 0, 2, 1},
|
||||
{192, 88, 99, 1},
|
||||
{192, 168, 1, 1},
|
||||
{198, 18, 0, 1},
|
||||
{198, 51, 100, 1},
|
||||
{203, 0, 113, 1},
|
||||
{224, 0, 0, 1},
|
||||
{240, 0, 0, 1},
|
||||
{255, 255, 255, 255}
|
||||
],
|
||||
lists:foreach(
|
||||
fun(Address) ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([Address]))
|
||||
)
|
||||
end,
|
||||
Blocked
|
||||
).
|
||||
|
||||
every_reserved_ipv6_range_is_refused_test() ->
|
||||
Blocked = [
|
||||
{0, 0, 0, 0, 0, 0, 0, 0},
|
||||
{0, 0, 0, 0, 0, 0, 0, 1},
|
||||
{16#2001, 16#0db8, 0, 0, 0, 0, 0, 1},
|
||||
{16#fd00, 0, 0, 0, 0, 0, 0, 1},
|
||||
{16#fe80, 0, 0, 0, 0, 0, 0, 1},
|
||||
{16#ff02, 0, 0, 0, 0, 0, 0, 1}
|
||||
],
|
||||
lists:foreach(
|
||||
fun(Address) ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([Address]))
|
||||
)
|
||||
end,
|
||||
Blocked
|
||||
).
|
||||
|
||||
ipv4_mapped_form_of_a_private_address_is_refused_test() ->
|
||||
Mapped = {0, 0, 0, 0, 0, 16#ffff, 16#0a00, 16#0001},
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([Mapped]))
|
||||
).
|
||||
|
||||
ipv4_compatible_and_nat64_and_sixtofour_forms_are_refused_test() ->
|
||||
Compatible = {0, 0, 0, 0, 0, 0, 16#a9fe, 16#a9fe},
|
||||
Nat64 = {16#0064, 16#ff9b, 0, 0, 0, 0, 16#0a00, 16#0001},
|
||||
SixToFour = {16#2002, 16#0a00, 16#0001, 0, 0, 0, 0, 0},
|
||||
lists:foreach(
|
||||
fun(Address) ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to([Address]))
|
||||
)
|
||||
end,
|
||||
[Compatible, Nat64, SixToFour]
|
||||
).
|
||||
|
||||
one_private_address_refuses_the_whole_set_test() ->
|
||||
Mixed = [{93, 184, 216, 34}, {10, 0, 0, 1}],
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to(Mixed))
|
||||
),
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to(lists:reverse(Mixed)))
|
||||
).
|
||||
|
||||
one_private_ipv6_address_refuses_the_whole_set_test() ->
|
||||
Mixed = [
|
||||
{93, 184, 216, 34},
|
||||
{16#2606, 16#4700, 16#4700, 0, 0, 0, 0, 16#1111},
|
||||
{16#fd00, 0, 0, 0, 0, 0, 0, 1}
|
||||
],
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(?ENDPOINT, resolves_to(Mixed))
|
||||
).
|
||||
|
||||
public_addresses_are_allowed_test() ->
|
||||
Public = [
|
||||
{93, 184, 216, 34},
|
||||
{16#2606, 16#4700, 16#4700, 0, 0, 0, 0, 16#1111},
|
||||
{0, 0, 0, 0, 0, 16#ffff, 16#5db8, 16#d822}
|
||||
],
|
||||
?assertEqual(ok, push_endpoint_guard:check(?ENDPOINT, resolves_to(Public))).
|
||||
|
||||
a_host_that_resolves_to_nothing_is_refused_test() ->
|
||||
?assertEqual({error, nxdomain}, push_endpoint_guard:check(?ENDPOINT, resolves_to([]))).
|
||||
|
||||
an_unresolvable_host_reports_the_resolver_error_test() ->
|
||||
?assertEqual({error, nxdomain}, push_endpoint_guard:check(?ENDPOINT, fails_with(nxdomain))),
|
||||
?assertEqual({error, timeout}, push_endpoint_guard:check(?ENDPOINT, fails_with(timeout))).
|
||||
|
||||
an_unresolvable_host_fails_cleanly_against_the_real_resolver_test() ->
|
||||
?assertMatch({error, _}, push_endpoint_guard:check(<<"https://push.invalid/sub">>)).
|
||||
|
||||
a_public_host_is_allowed_by_the_real_resolver_test() ->
|
||||
case inet:getaddrs("one.one.one.one", inet, 3000) of
|
||||
{ok, [_ | _]} ->
|
||||
?assertEqual(ok, push_endpoint_guard:check(<<"https://one.one.one.one/sub">>));
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
plain_http_is_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_rejected},
|
||||
push_endpoint_guard:check(
|
||||
<<"http://push.example.com/sub">>, resolves_to([{1, 1, 1, 1}])
|
||||
)
|
||||
).
|
||||
|
||||
non_standard_ports_are_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_rejected},
|
||||
push_endpoint_guard:check(
|
||||
<<"https://push.example.com:8080/sub">>, resolves_to([{1, 1, 1, 1}])
|
||||
)
|
||||
),
|
||||
?assertEqual(
|
||||
ok,
|
||||
push_endpoint_guard:check(
|
||||
<<"https://push.example.com:443/sub">>, resolves_to([{1, 1, 1, 1}])
|
||||
)
|
||||
).
|
||||
|
||||
userinfo_is_refused_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_rejected},
|
||||
push_endpoint_guard:check(
|
||||
<<"https://[email protected]/sub">>, resolves_to([{1, 1, 1, 1}])
|
||||
)
|
||||
),
|
||||
?assertEqual(
|
||||
{error, endpoint_rejected},
|
||||
push_endpoint_guard:check(
|
||||
<<"https://user:[email protected]/sub">>, resolves_to([{1, 1, 1, 1}])
|
||||
)
|
||||
).
|
||||
|
||||
ip_literals_skip_dns_and_are_screened_directly_test() ->
|
||||
Never = fun(_Host) -> erlang:error(resolver_called) end,
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(<<"https://169.254.169.254/latest">>, Never)
|
||||
),
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked}, push_endpoint_guard:check(<<"https://127.0.0.1/sub">>, Never)
|
||||
),
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked}, push_endpoint_guard:check(<<"https://[::1]/sub">>, Never)
|
||||
),
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(<<"https://[::ffff:10.0.0.1]/sub">>, Never)
|
||||
),
|
||||
?assertEqual(ok, push_endpoint_guard:check(<<"https://93.184.216.34/sub">>, Never)).
|
||||
|
||||
malformed_and_non_fqdn_hosts_are_refused_test() ->
|
||||
Never = fun(_Host) -> erlang:error(resolver_called) end,
|
||||
lists:foreach(
|
||||
fun(Endpoint) ->
|
||||
?assertEqual(
|
||||
{error, endpoint_rejected}, push_endpoint_guard:check(Endpoint, Never)
|
||||
)
|
||||
end,
|
||||
[
|
||||
<<"not-a-url">>,
|
||||
<<>>,
|
||||
<<"https://localhost/sub">>,
|
||||
<<"https://metadata/sub">>,
|
||||
<<"https://push.example.123/sub">>,
|
||||
<<"https://-push.example.com/sub">>,
|
||||
<<"ftp://push.example.com/sub">>
|
||||
]
|
||||
).
|
||||
|
||||
uppercase_hosts_are_normalised_test() ->
|
||||
?assertEqual(
|
||||
{error, endpoint_blocked},
|
||||
push_endpoint_guard:check(
|
||||
<<"https://PUSH.EXAMPLE.COM/sub">>, resolves_to([{10, 0, 0, 1}])
|
||||
)
|
||||
).
|
||||
@@ -50,7 +50,7 @@ concurrent_subscription_reads_and_writes_keep_cache_consistent() ->
|
||||
end.
|
||||
|
||||
put_subscription_for_even_user(UserId) when UserId rem 2 =:= 0 ->
|
||||
ok = push_ets_cache:put_subscriptions(UserId, [#{endpoint => endpoint(UserId)}]);
|
||||
ok = put_subscriptions(UserId, [#{endpoint => endpoint(UserId)}]);
|
||||
put_subscription_for_even_user(_UserId) ->
|
||||
ok.
|
||||
|
||||
@@ -68,7 +68,7 @@ writer_loop(WriterIndex, Count) ->
|
||||
End = Start + Count - 1,
|
||||
lists:foreach(
|
||||
fun(UserId) ->
|
||||
ok = push_ets_cache:put_subscriptions(UserId, [#{endpoint => endpoint(UserId)}])
|
||||
ok = put_subscriptions(UserId, [#{endpoint => endpoint(UserId)}])
|
||||
end,
|
||||
lists:seq(Start, End)
|
||||
).
|
||||
@@ -103,6 +103,11 @@ collect_done(Ref, Message, Remaining) ->
|
||||
?assert(false)
|
||||
end.
|
||||
|
||||
put_subscriptions(UserId, Subscriptions) ->
|
||||
push_ets_cache:put_subscriptions(
|
||||
UserId, Subscriptions, push_ets_cache:reserve_subscriptions([UserId])
|
||||
).
|
||||
|
||||
endpoint(UserId) ->
|
||||
UserIdBin = integer_to_binary(UserId),
|
||||
<<"endpoint-", UserIdBin/binary>>.
|
||||
|
||||
@@ -34,7 +34,7 @@ subscriptions_test() ->
|
||||
cleanup_tables(),
|
||||
ok = push_ets_cache:init(),
|
||||
?assertEqual(undefined, push_ets_cache:get_subscriptions(1)),
|
||||
ok = push_ets_cache:put_subscriptions(1, [sub1, sub2]),
|
||||
ok = seed_subscriptions(1, [sub1, sub2]),
|
||||
?assertEqual([sub1, sub2], push_ets_cache:get_subscriptions(1)),
|
||||
ok = push_ets_cache:delete_subscriptions(1),
|
||||
?assertEqual(undefined, push_ets_cache:get_subscriptions(1)),
|
||||
@@ -52,7 +52,7 @@ badge_count_test() ->
|
||||
cleanup_tables(),
|
||||
ok = push_ets_cache:init(),
|
||||
?assertEqual(undefined, push_ets_cache:get_badge_count(1)),
|
||||
ok = push_ets_cache:put_badge_count(1, 5, 1000),
|
||||
ok = seed_badge_count(1, 5, 1000),
|
||||
?assertEqual({5, 1000}, push_ets_cache:get_badge_count(1)),
|
||||
ok = push_ets_cache:delete_badge_count(1),
|
||||
?assertEqual(undefined, push_ets_cache:get_badge_count(1)),
|
||||
@@ -61,20 +61,21 @@ badge_count_test() ->
|
||||
badge_count_keeps_fresher_timestamp_test() ->
|
||||
cleanup_tables(),
|
||||
ok = push_ets_cache:init(),
|
||||
ok = push_ets_cache:put_badge_count(1, 5, 2000),
|
||||
ok = push_ets_cache:put_badge_count(1, 9, 1000),
|
||||
First = push_ets_cache:reserve_badge_counts([1]),
|
||||
ok = push_ets_cache:put_badge_count(1, 5, 2000, First),
|
||||
ok = push_ets_cache:put_badge_count(1, 9, 1000, First),
|
||||
?assertEqual({5, 2000}, push_ets_cache:get_badge_count(1)),
|
||||
ok = push_ets_cache:put_badge_count(1, 7, 3000),
|
||||
ok = seed_badge_count(1, 7, 3000),
|
||||
?assertEqual({7, 3000}, push_ets_cache:get_badge_count(1)),
|
||||
ok = push_ets_cache:put_badge_count(1, 8, 3000),
|
||||
ok = seed_badge_count(1, 8, 3000),
|
||||
?assertEqual({8, 3000}, push_ets_cache:get_badge_count(1)),
|
||||
cleanup_tables().
|
||||
|
||||
cache_stats_test() ->
|
||||
cleanup_tables(),
|
||||
ok = push_ets_cache:init(),
|
||||
ok = push_ets_cache:put_subscriptions(1, []),
|
||||
ok = push_ets_cache:put_subscriptions(2, []),
|
||||
ok = seed_subscriptions(1, []),
|
||||
ok = seed_subscriptions(2, []),
|
||||
Stats = push_ets_cache:cache_stats(),
|
||||
?assertEqual(2, maps:get(push_subscriptions_size, Stats)),
|
||||
?assertEqual(0, maps:get(user_guild_settings_size, Stats)),
|
||||
@@ -83,7 +84,7 @@ cache_stats_test() ->
|
||||
evict_tables_test() ->
|
||||
cleanup_tables(),
|
||||
ok = push_ets_cache:init(),
|
||||
lists:foreach(fun(I) -> push_ets_cache:put_subscriptions(I, []) end, lists:seq(1, 10)),
|
||||
lists:foreach(fun(I) -> ok = seed_subscriptions(I, []) end, lists:seq(1, 10)),
|
||||
?assertEqual(10, push_ets_cache:table_size(push_subscriptions)),
|
||||
ok = push_ets_cache:evict_tables(#{subscriptions => 5}),
|
||||
?assertEqual(5, push_ets_cache:table_size(push_subscriptions)),
|
||||
@@ -98,8 +99,8 @@ rebalance_evicts_remote_owned_entries_test() ->
|
||||
RoleMap = #{push => Members, all => Members},
|
||||
persistent_term:put({gateway_cluster_membership, members}, Members),
|
||||
persistent_term:put({gateway_cluster_membership, members_by_role}, RoleMap),
|
||||
ok = push_ets_cache:put_subscriptions(LocalUserId, [local]),
|
||||
ok = push_ets_cache:put_subscriptions(RemoteUserId, [remote]),
|
||||
ok = seed_subscriptions(LocalUserId, [local]),
|
||||
ok = seed_subscriptions(RemoteUserId, [remote]),
|
||||
ok = push_ets_cache:put_user_guild_settings(LocalUserId, 10, #{local => true}),
|
||||
ok = push_ets_cache:put_user_guild_settings(RemoteUserId, 10, #{remote => true}),
|
||||
ok = push_ets_cache:rebalance(),
|
||||
@@ -111,6 +112,16 @@ rebalance_evicts_remote_owned_entries_test() ->
|
||||
persistent_term:erase({gateway_cluster_membership, members_by_role}),
|
||||
cleanup_tables().
|
||||
|
||||
seed_subscriptions(UserId, Subscriptions) ->
|
||||
push_ets_cache:put_subscriptions(
|
||||
UserId, Subscriptions, push_ets_cache:reserve_subscriptions([UserId])
|
||||
).
|
||||
|
||||
seed_badge_count(UserId, Count, CachedAt) ->
|
||||
push_ets_cache:put_badge_count(
|
||||
UserId, Count, CachedAt, push_ets_cache:reserve_badge_counts([UserId])
|
||||
).
|
||||
|
||||
find_split_user_ids(Members, RemoteNode) ->
|
||||
Local =
|
||||
hd([
|
||||
@@ -131,6 +142,7 @@ cleanup_tables() ->
|
||||
delete_table(push_subscriptions),
|
||||
delete_table(push_blocked_ids),
|
||||
delete_table(push_badge_counts),
|
||||
delete_table(push_bearer_tokens),
|
||||
ok.
|
||||
|
||||
delete_table(Table) ->
|
||||
|
||||
@@ -33,15 +33,6 @@ wrap_avatar_index_test() ->
|
||||
?assertEqual(0, push_utils:wrap_avatar_index(6)),
|
||||
?assertEqual(1, push_utils:wrap_avatar_index(7)).
|
||||
|
||||
parse_timestamp_valid_test() ->
|
||||
?assertEqual(123456789, push_utils:parse_timestamp(<<"123456789">>)),
|
||||
?assertEqual(0, push_utils:parse_timestamp(<<"0">>)).
|
||||
|
||||
parse_timestamp_invalid_test() ->
|
||||
?assertEqual(undefined, push_utils:parse_timestamp(<<"not_a_number">>)),
|
||||
?assertEqual(undefined, push_utils:parse_timestamp(123)),
|
||||
?assertEqual(undefined, push_utils:parse_timestamp(undefined)).
|
||||
|
||||
base64url_encode_test() ->
|
||||
Encoded = push_utils:base64url_encode(<<"test">>),
|
||||
?assert(is_binary(Encoded)).
|
||||
@@ -50,6 +41,28 @@ base64url_decode_test() ->
|
||||
Encoded = push_utils:base64url_encode(<<"test">>),
|
||||
?assertEqual(<<"test">>, push_utils:base64url_decode(Encoded)).
|
||||
|
||||
plaintext_budget_test() ->
|
||||
?assertEqual(2713, push_utils:plaintext_budget(2816)),
|
||||
?assertEqual(3993, push_utils:plaintext_budget(4096)),
|
||||
?assertEqual(0, push_utils:plaintext_budget(1)).
|
||||
|
||||
encrypt_payload_fills_the_record_at_the_budget_test() ->
|
||||
RecordSize = push_sender_retry:initial_record_size(),
|
||||
Budget = push_utils:plaintext_budget(RecordSize),
|
||||
{PeerPub, _PeerPriv} = crypto:generate_key(ecdh, prime256v1),
|
||||
P256dh = push_utils:base64url_encode(PeerPub),
|
||||
Auth = push_utils:base64url_encode(crypto:strong_rand_bytes(16)),
|
||||
{ok, Body} = push_utils:encrypt_payload(
|
||||
binary:copy(<<"x">>, Budget), P256dh, Auth, RecordSize
|
||||
),
|
||||
?assertEqual(RecordSize, byte_size(Body)),
|
||||
?assertEqual(
|
||||
{error, max_pad_exceeded},
|
||||
push_utils:encrypt_payload(
|
||||
binary:copy(<<"x">>, Budget + 1), P256dh, Auth, RecordSize
|
||||
)
|
||||
).
|
||||
|
||||
hkdf_expand_test() ->
|
||||
IKM = crypto:strong_rand_bytes(32),
|
||||
Salt = crypto:strong_rand_bytes(16),
|
||||
|
||||
Reference in New Issue
Block a user