Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ on:

jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@dc560fbe8e4e39898dd808645fc1d3e69d248429 # main as of 2026-03-31, pinned via Dependabot
uses: Taure/erlang-ci/.github/workflows/ci.yml@559dea550228fb7042813f6b6359addec11bedcf # main as of 2026-05-20, pinned via Dependabot
permissions:
contents: write
pull-requests: write
with:
version-file: '.tool-versions'
enable-audit: true
# GHSA-g2wm-735q-3f56 (LOW, cowlib cookie/1) has no upstream patch
# and does not apply: asobi only calls cow_cookie:setcookie via
# cowboy. Tracked in docs/security_audit_2026_05_19.md.
audit-ignores: 'GHSA-g2wm-735q-3f56'
enable-ct: true
extra-services-compose: 'docker-compose.yml'
enable-dependency-submission: true
Expand Down
6 changes: 6 additions & 0 deletions config/dev_sys.config.src
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
{bootstrap_application, asobi},
{json_lib, json},
{plugins, [
%% H2 (2026-05-19): cap HTTP body size before nova_request_plugin
%% buffers it into the heap. Default 1 MiB; dev allows the same.
{pre_request, asobi_body_cap_plugin, #{
max_body => 1048576,
require_content_length => true
}},
{pre_request, nova_request_plugin, #{
decode_json_body => true,
parse_qs => true
Expand Down
7 changes: 7 additions & 0 deletions config/prod_sys.config.src
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
{bootstrap_application, asobi},
{json_lib, json},
{plugins, [
%% H2 (2026-05-19): cap HTTP body size before nova_request_plugin
%% buffers it into the heap. Default 1 MiB; per-route checks
%% (save/storage) still apply on top of this floor.
{pre_request, asobi_body_cap_plugin, #{
max_body => 1048576,
require_content_length => true
}},
{pre_request, nova_request_plugin, #{
decode_json_body => true,
parse_qs => true
Expand Down
150 changes: 150 additions & 0 deletions docs/security_audit_2026_05_19.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{shell, [{config, "./config/dev_sys.config.src"}]}.

{deps, [
nova,
{nova, "~> 0.14.3"},
{kura, {git, "https://github.com/Taure/kura.git", {tag, "v2.0.4"}}},
{kura_postgres, {git, "https://github.com/Taure/kura_postgres.git", {tag, "v0.4.2"}}},
{nova_auth, "~> 0.1"},
Expand All @@ -21,6 +21,12 @@
{shigoto, "~> 1.2"}
]}.

%% H4 (2026-05-19): the upstream cowboy hex package declares its cowlib/ranch
%% deps with an "and" syntax that rebar3 cannot parse, so each consumer must
%% override transitively. Pin cowlib to the patched 2.16.1 to clear the
%% rebar3 audit advisory against 2.16.0.
{overrides, [{override, cowboy, [{deps, [{cowlib, "2.16.1"}, {ranch, "2.2.0"}]}]}]}.

{relx, [
{release, {asobi, git}, [
asobi,
Expand Down
24 changes: 12 additions & 12 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{"1.2.0",
[{<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},2},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},1},
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},2},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.15.0">>},1},
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.1">>},2},
{<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},1},
{<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.4.0">>},1},
{<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.11.2">>},1},
{<<"jose">>,{pkg,<<"jose">>,<<"1.11.12">>},2},
{<<"kura">>,
{git,"https://github.com/Taure/kura.git",
Expand All @@ -13,7 +13,7 @@
{git,"https://github.com/Taure/kura_postgres.git",
{ref,"30651b35986bda5fc84d89fc141936cb70b636ab"}},
0},
{<<"nova">>,{pkg,<<"nova">>,<<"0.14.1">>},0},
{<<"nova">>,{pkg,<<"nova">>,<<"0.14.3">>},0},
{<<"nova_auth">>,{pkg,<<"nova_auth">>,<<"0.1.1">>},0},
{<<"nova_auth_oidc">>,{pkg,<<"nova_auth_oidc">>,<<"0.1.0">>},0},
{<<"nova_resilience">>,{pkg,<<"nova_resilience">>,<<"1.0.3">>},0},
Expand All @@ -31,12 +31,12 @@
[
{pkg_hash,[
{<<"backoff">>, <<"83B72ED2108BA1EE8F7D1C22E0B4A00CFE3593A67DBC792799E8CCE9F42F796B">>},
{<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>},
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
{<<"cowboy">>, <<"9CFE86ED7117BF045E10ADBEDB0170AF7BE57F2A3637E7BE143433D8DD267396">>},
{<<"cowlib">>, <<"318D385D55F657E9A5005838C4E426E13DCD724A691438384B6165A69687E531">>},
{<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>},
{<<"jhn_stdlib">>, <<"FAC6F19B35351278F1CB156E23A5B2A6047A9DD5AB1FD9E1189A7918006DF7ED">>},
{<<"jhn_stdlib">>, <<"785074F3CA368EAA8E9AF1592BC19AE9EF1F7AF30B2CD6456A6083173A8F5CCB">>},
{<<"jose">>, <<"06E62B467B61D3726CBC19E9B5489F7549C37993DE846DFB3EE8259F9ED208B3">>},
{<<"nova">>, <<"426AAA12DFB38E5CA4EC6EE3AA6066917BDCBD91C00BE53BF875161963CBA216">>},
{<<"nova">>, <<"2F08F11162A871CB1AA361BFEFE66D945F3284243F7EA6582AD8CA709BAFE2F3">>},
{<<"nova_auth">>, <<"59C481D5AF498AF8780936FB428E22320051D63A900F7D63E3692D302913369A">>},
{<<"nova_auth_oidc">>, <<"C0077E345186F945CA12701366ED9953131CF585E5E91B26D6608AC0116D7B88">>},
{<<"nova_resilience">>, <<"353C95496647B8FFF390D3367F2BA126F2998F6EACFFC4F06BA900CD401B3441">>},
Expand All @@ -53,12 +53,12 @@
{<<"thoas">>, <<"19A25F31177A17E74004D4840F66D791D4298C5738790FA2CC73731EB911F195">>}]},
{pkg_hash_ext,[
{<<"backoff">>, <<"CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39">>},
{<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>},
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
{<<"cowboy">>, <<"179FB65140FB440A17B767AD53B755081506F9596C4DB5C49C0396D8C8643668">>},
{<<"cowlib">>, <<"58F1E425A9E04176F1D30E20116F57C4E90EF0E187552E9741C465BDF4044F70">>},
{<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>},
{<<"jhn_stdlib">>, <<"7EABD1B01D2DEFF495BF7C5CA1DBA4D3FA0B84DC3AF03CA85F31D52EBB03C6FC">>},
{<<"jhn_stdlib">>, <<"2329CD16DEE46704AAB6184D09508E59DBA31C4D3255271DBB7D34D115ECA508">>},
{<<"jose">>, <<"31E92B653E9210B696765CDD885437457DE1ADD2A9011D92F8CF63E4641BAB7B">>},
{<<"nova">>, <<"6E932989B70F4E235EBC8C7A6D0560E4B22A18760A651129BC84FA1A90C05B34">>},
{<<"nova">>, <<"D35D0CEF073749958D2E7E446DED07354F5A3A6B96A31009A2E89D382BE72EF4">>},
{<<"nova_auth">>, <<"57CBF808DCA21CA4BDB877A92476E78622240A633DC61DCF9C80F846E32574B3">>},
{<<"nova_auth_oidc">>, <<"E7343A22815144E1ABFD1F73D93904FD0960E8A070E3C831FB5AE9BB0FABD69B">>},
{<<"nova_resilience">>, <<"FFF1FE82634386A944553B2B2483AB3D140DAEA27A24A42AA1AFDD3E0BB97B0B">>},
Expand Down
71 changes: 1 addition & 70 deletions src/controllers/asobi_chat_controller.erl
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ history(
) when
is_binary(ChannelId), is_binary(Qs), is_binary(PlayerId)
->
case authorized(ChannelId, PlayerId) of
case asobi_chat_acl:authorized(ChannelId, PlayerId) of
true ->
Params = cow_qs:parse_qs(Qs),
Limit = asobi_qs:integer(
Expand All @@ -35,72 +35,3 @@ history(
end;
history(_Req) ->
{json, 400, #{}, #{error => ~"invalid_request"}}.

%% Channel ID schemes (see asobi_dm:channel_id/2 and asobi_world_chat:channel_id/3):
%% dm:<A>:<B> — A and B are the only allowed readers
%% world:<WorldId> — must be currently joined to the world
%% zone:<WorldId>:<X>,<Y> — must be currently joined to the world
%% prox:<WorldId>:<X>,<Y> — must be currently joined to the world
%% <anything else> — treated as a group_id; must be a group member
-spec authorized(binary(), binary()) -> boolean().
authorized(ChannelId, PlayerId) ->
case classify(ChannelId) of
{dm, A, B} ->
PlayerId =:= A orelse PlayerId =:= B;
{world, WorldId} ->
player_in_world(PlayerId, WorldId);
{group, GroupId} ->
is_group_member(PlayerId, GroupId)
end.

-spec classify(binary()) -> {dm, binary(), binary()} | {world, binary()} | {group, binary()}.
classify(<<"dm:", Rest/binary>>) ->
case binary:split(Rest, ~":", [global]) of
[A, B] when byte_size(A) > 0, byte_size(B) > 0 -> {dm, A, B};
_ -> {group, <<"dm:", Rest/binary>>}
end;
classify(<<"world:", WorldId/binary>>) when byte_size(WorldId) > 0 ->
{world, WorldId};
classify(<<"zone:", Rest/binary>>) ->
{world, take_until_colon(Rest)};
classify(<<"prox:", Rest/binary>>) ->
{world, take_until_colon(Rest)};
classify(ChannelId) ->
{group, ChannelId}.

-spec take_until_colon(binary()) -> binary().
take_until_colon(Bin) ->
case binary:split(Bin, ~":") of
[Head, _] -> Head;
[Head] -> Head
end.

-spec player_in_world(binary(), binary()) -> boolean().
player_in_world(PlayerId, WorldId) ->
case asobi_world_server:whereis(WorldId) of
{ok, Pid} ->
try asobi_world_server:get_info(Pid) of
#{players := Players} when is_list(Players) ->
lists:member(PlayerId, Players);
_ ->
false
catch
_:_ -> false
end;
error ->
false
end.

-spec is_group_member(binary(), binary()) -> boolean().
is_group_member(PlayerId, GroupId) ->
Q = kura_query:where(
kura_query:where(
kura_query:from(asobi_group_member),
{group_id, GroupId}
),
{player_id, PlayerId}
),
case asobi_repo:all(Q) of
{ok, [_ | _]} -> true;
_ -> false
end.
6 changes: 4 additions & 2 deletions src/controllers/asobi_world_controller.erl
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
-spec index(map()) -> {json, map()}.
index(#{parsed_qs := QS}) ->
Filters = build_filters(QS),
Worlds = asobi_world_lobby:list_worlds(Filters),
%% H3 (2026-05-19): use cached enumeration; freshness is 500ms which is
%% well below the granularity a polling client perceives.
Worlds = asobi_world_lobby:list_worlds_cached(Filters),
{json, #{worlds => Worlds}};
index(_Req) ->
Worlds = asobi_world_lobby:list_worlds(),
Worlds = asobi_world_lobby:list_worlds_cached(),
{json, #{worlds => Worlds}}.

-spec show(map()) -> {json, map()} | {status, 404}.
Expand Down
77 changes: 77 additions & 0 deletions src/plugins/asobi_body_cap_plugin.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-module(asobi_body_cap_plugin).
-behaviour(nova_plugin).
-moduledoc """
Pre-request plugin that caps HTTP request body size.

Runs before `nova_request_plugin` so we can short-circuit oversized requests
with 413 before any body bytes are buffered into BEAM heap.

H2 (2026-05-19): without this cap, an authenticated client could POST a
multi-GB JSON body to any `/api/v1/**` endpoint and OOM the node before the
controller's per-route check (e.g. `MAX_SAVE_DATA_BYTES`) ever ran.

Options:
max_body => non_neg_integer() %% bytes, default 1 MiB
require_content_length => boolean()%% reject chunked w/o content-length, default true
""".

-export([pre_request/4, post_request/4, plugin_info/0]).

-define(DEFAULT_MAX_BODY, 1048576).

-spec pre_request(cowboy_req:req(), map(), map(), term()) ->
{ok, cowboy_req:req(), term()} | {break, cowboy_req:req(), term()}.
pre_request(Req, _Env, Options, State) ->
Max = maps:get(max_body, Options, ?DEFAULT_MAX_BODY),
RequireCL = maps:get(require_content_length, Options, true),
case needs_check(Req) of
false ->
{ok, Req, State};
true ->
check_size(Req, Max, RequireCL, State)
end.

-spec post_request(cowboy_req:req(), map(), map(), term()) ->
{ok, cowboy_req:req(), term()}.
post_request(Req, _Env, _Options, State) ->
{ok, Req, State}.

-spec plugin_info() -> map().
plugin_info() ->
#{
title => ~"Body Size Cap",
version => ~"1.0.0",
url => ~"https://github.com/widgrensit/asobi",
authors => [~"widgrensit"],
description => ~"Rejects oversized HTTP request bodies before they are buffered"
}.

-spec needs_check(cowboy_req:req()) -> boolean().
needs_check(Req) ->
cowboy_req:has_body(Req).

-spec check_size(cowboy_req:req(), non_neg_integer(), boolean(), term()) ->
{ok, cowboy_req:req(), term()} | {break, cowboy_req:req(), term()}.
check_size(Req, Max, RequireCL, State) ->
case cowboy_req:body_length(Req) of
undefined when RequireCL ->
reject(411, ~"length_required", Req, State);
undefined ->
{ok, Req, State};
N when is_integer(N), N > Max ->
reject(413, ~"payload_too_large", Req, State);
_ ->
{ok, Req, State}
end.

-spec reject(integer(), binary(), cowboy_req:req(), term()) ->
{break, cowboy_req:req(), term()}.
reject(Status, Reason, Req, State) ->
Body = json:encode(#{~"error" => Reason}),
Req1 = cowboy_req:reply(
Status,
#{~"content-type" => ~"application/json"},
Body,
Req
),
{break, Req1, State}.
80 changes: 80 additions & 0 deletions src/social/asobi_chat_acl.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-module(asobi_chat_acl).
-moduledoc """
Authorisation policy for chat channels.

Channel ID schemes:
dm:<A>:<B> - A and B are the only allowed readers
world:<WorldId> - must currently be joined to the world
zone:<WorldId>:<X>,<Y> - must currently be joined to the world
prox:<WorldId>:<X>,<Y> - must currently be joined to the world
<anything else> - treated as a group_id; must be a group member

Shared by `asobi_chat_controller` (HTTP history) and `asobi_ws_handler`
(WebSocket `chat.join` / `chat.send`). Keeping a single source of truth
prevents the WS path from drifting and silently allowing DM eavesdropping.
""".

-export([authorized/2]).

-spec authorized(binary(), binary()) -> boolean().
authorized(ChannelId, PlayerId) when is_binary(ChannelId), is_binary(PlayerId) ->
case classify(ChannelId) of
{dm, A, B} ->
PlayerId =:= A orelse PlayerId =:= B;
{world, WorldId} ->
player_in_world(PlayerId, WorldId);
{group, GroupId} ->
is_group_member(PlayerId, GroupId)
end.

-spec classify(binary()) -> {dm, binary(), binary()} | {world, binary()} | {group, binary()}.
classify(<<"dm:", Rest/binary>>) ->
case binary:split(Rest, ~":", [global]) of
[A, B] when byte_size(A) > 0, byte_size(B) > 0 -> {dm, A, B};
_ -> {group, <<"dm:", Rest/binary>>}
end;
classify(<<"world:", WorldId/binary>>) when byte_size(WorldId) > 0 ->
{world, WorldId};
classify(<<"zone:", Rest/binary>>) ->
{world, take_until_colon(Rest)};
classify(<<"prox:", Rest/binary>>) ->
{world, take_until_colon(Rest)};
classify(ChannelId) ->
{group, ChannelId}.

-spec take_until_colon(binary()) -> binary().
take_until_colon(Bin) ->
case binary:split(Bin, ~":") of
[Head, _] -> Head;
[Head] -> Head
end.

-spec player_in_world(binary(), binary()) -> boolean().
player_in_world(PlayerId, WorldId) ->
case asobi_world_server:whereis(WorldId) of
{ok, Pid} ->
try asobi_world_server:get_info(Pid) of
#{players := Players} when is_list(Players) ->
lists:member(PlayerId, Players);
_ ->
false
catch
_:_ -> false
end;
error ->
false
end.

-spec is_group_member(binary(), binary()) -> boolean().
is_group_member(PlayerId, GroupId) ->
Q = kura_query:where(
kura_query:where(
kura_query:from(asobi_group_member),
{group_id, GroupId}
),
{player_id, PlayerId}
),
case asobi_repo:all(Q) of
{ok, [_ | _]} -> true;
_ -> false
end.
Loading
Loading