feat(push): relay notifications as encrypted web push (#2906)

This commit is contained in:
Hampus
2026-09-23 14:04:55 +02:00
committed by GitHub
parent 7021a58090
commit 18c303abf6
111 changed files with 12473 additions and 1177 deletions
@@ -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>>.
+23 -11
View File
@@ -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) ->
+22 -9
View File
@@ -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),