Harden public contact verification lifecycle

This commit is contained in:
SimpleTest 2026-08-03 02:01:09 +03:00
parent accc0845bd
commit 7be5fcf478
16 changed files with 471 additions and 24 deletions

View File

@ -227,6 +227,11 @@ SUPPORT_INBOX_ADDRESS=
# staff workspace is the canonical queue. Set immediate only when a monitored # staff workspace is the canonical queue. Set immediate only when a monitored
# mailbox should receive one metadata-only alert for each verified case/update. # mailbox should receive one metadata-only alert for each verified case/update.
SUPPORT_OPERATOR_EMAIL_MODE=disabled SUPPORT_OPERATOR_EMAIL_MODE=disabled
# Pilot policy: an anonymous support/removal email must be confirmed within one
# day. Confirmed case links remain usable for one year. Both values are seconds
# and can be changed without rebuilding the release.
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
CODEX_SESSION_ID=copy-the-main-local-codex-session-id CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved. # Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.

View File

@ -30,6 +30,8 @@ x-app-environment: &app-environment
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured} CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}} RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400}
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000}
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}

View File

@ -34,6 +34,8 @@ config :who_need_help,
e2e_routes: false, e2e_routes: false,
secure_cookies: false, secure_cookies: false,
rate_limit_policies: %{}, rate_limit_policies: %{},
public_contact_verification_max_age_seconds: 86_400,
public_case_access_max_age_seconds: 31_536_000,
tracking_presence_cleanup_grace_ms: 5_000, tracking_presence_cleanup_grace_ms: 5_000,
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
android_app_links: nil, android_app_links: nil,

View File

@ -82,6 +82,12 @@ positive_integer_with_default = fn name, default ->
optional_positive_integer.(name) || default optional_positive_integer.(name) || default
end end
config :who_need_help,
public_contact_verification_max_age_seconds:
positive_integer_with_default.("PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS", 86_400),
public_case_access_max_age_seconds:
positive_integer_with_default.("PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS", 31_536_000)
if config_env() == :prod do if config_env() == :prod do
config :who_need_help, Oban, config :who_need_help, Oban,
queues: [ queues: [

View File

@ -101,6 +101,10 @@ spec:
value: {{ $root.Values.app.codexSessionId | quote }} value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON - name: RATE_LIMIT_POLICIES_JSON
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }} value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
- name: PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicContactVerificationMaxAgeSeconds | quote }}
- name: PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicCaseAccessMaxAgeSeconds | quote }}
- name: MAP_TILE_URL - name: MAP_TILE_URL
value: {{ $root.Values.app.mapTileUrl | quote }} value: {{ $root.Values.app.mapTileUrl | quote }}
- name: DNS_CLUSTER_QUERY - name: DNS_CLUSTER_QUERY

View File

@ -32,6 +32,8 @@ app:
codexSessionId: not-configured codexSessionId: not-configured
# Shared limits are opt-in; set only after product policy thresholds are approved. # Shared limits are opt-in; set only after product policy thresholds are approved.
rateLimitPoliciesJson: "{}" rateLimitPoliciesJson: "{}"
publicContactVerificationMaxAgeSeconds: "86400"
publicCaseAccessMaxAgeSeconds: "31536000"
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
emailDeliveryProvider: smtp emailDeliveryProvider: smtp
smtpRelay: mailpit smtpRelay: mailpit

View File

@ -119,6 +119,16 @@ intake and reporter acknowledgement still work, but no operator inbox alert is
sent. The configured inbox must be monitored operationally; the application sent. The configured inbox must be monitored operationally; the application
cannot prove staffing or response availability. cannot prove staffing or response availability.
Anonymous submissions use two distinct private links. The confirmation link is
valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot
policy is 24 hours). Confirming it moves the record into the permission-scoped
staff queue and sends a second status link. The status link lifetime is
`PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS` (the initial pilot policy is one year).
Expired unconfirmed records are removed by the maintenance worker together with
their creation audit entry; confirmed support and legal records are outside this
cleanup. Each deployment can change both values through its own `.env` without
rebuilding the release.
## Account deletion and data export ## Account deletion and data export
The web application and Android WebView expose the account-deletion request from The web application and Android WebView expose the account-deletion request from

View File

@ -12,6 +12,7 @@ defmodule WhoNeedHelp.ContentRemoval do
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
@access_salt "content-removal-access" @access_salt "content-removal-access"
@confirmation_salt "content-removal-confirmation"
@urgent_categories [ @urgent_categories [
:non_consensual_intimate_media, :non_consensual_intimate_media,
:child_sexual_abuse_material, :child_sexual_abuse_material,
@ -88,9 +89,15 @@ defmodule WhoNeedHelp.ContentRemoval do
def get_by_access_token(id, token) when is_binary(token) do def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <- {:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000), Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: case_access_max_age_seconds()
),
%Notice{} = notice <- Repo.get(Notice, id) do %Notice{} = notice <- Repo.get(Notice, id) do
verify_contact(notice) if notice.contact_verified_at do
{:ok, notice}
else
verify_legacy_confirmation(notice, token)
end
else else
_ -> {:error, :not_found} _ -> {:error, :not_found}
end end
@ -98,8 +105,31 @@ defmodule WhoNeedHelp.ContentRemoval do
def get_by_access_token(_id, _token), do: {:error, :not_found} def get_by_access_token(_id, _token), do: {:error, :not_found}
def verify_by_confirmation_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
max_age: contact_verification_max_age_seconds()
),
%Notice{} = notice <- Repo.get(Notice, id) do
verify_contact(notice)
else
_ -> {:error, :not_found}
end
end
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
def access_token(%Notice{id: id}) do def access_token(%Notice{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id) Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
max_age: case_access_max_age_seconds()
)
end
def confirmation_token(%Notice{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
max_age: contact_verification_max_age_seconds()
)
end end
def status_url(%Notice{} = notice) do def status_url(%Notice{} = notice) do
@ -109,6 +139,13 @@ defmodule WhoNeedHelp.ContentRemoval do
"/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}" "/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}"
end end
def confirmation_url(%Notice{} = notice) do
token = confirmation_token(notice)
WhoNeedHelpWeb.Endpoint.url() <>
"/legal/content-removal/#{notice.id}/verify?token=#{URI.encode_www_form(token)}"
end
def paginate_for_staff(%Scope{user: user}, options \\ []) do def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :legal_view) do if Accounts.authorized?(user, :legal_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
@ -194,7 +231,7 @@ defmodule WhoNeedHelp.ContentRemoval do
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice}) defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
when is_binary(email) and email != "" do when is_binary(email) and email != "" do
case Notifier.deliver_confirmation(notice, status_url(notice)) do case Notifier.deliver_confirmation(notice, confirmation_url(notice)) do
{:ok, _metadata} -> mark_acknowledgement_sent(notice) {:ok, _metadata} -> mark_acknowledgement_sent(notice)
_error -> {:ok, notice} _error -> {:ok, notice}
end end
@ -270,6 +307,7 @@ defmodule WhoNeedHelp.ContentRemoval do
defp verify_contact(%Notice{} = notice), do: {:ok, notice} defp verify_contact(%Notice{} = notice), do: {:ok, notice}
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
_ = Notifier.deliver_received(notice, status_url(notice))
_ = Notifier.deliver_operator_alert(notice) _ = Notifier.deliver_operator_alert(notice)
{:ok, notice} {:ok, notice}
end end
@ -277,6 +315,23 @@ defmodule WhoNeedHelp.ContentRemoval do
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice} defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
defp notify_verified_notice(result), do: result defp notify_verified_notice(result), do: result
defp verify_legacy_confirmation(%Notice{id: id} = notice, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds()
) do
{:ok, ^id} -> verify_contact(notice)
_error -> {:error, :not_found}
end
end
defp contact_verification_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
end
defp case_access_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end
defp mark_acknowledgement_sent(notice) do defp mark_acknowledgement_sent(notice) do
notice notice
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second)) |> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))

View File

@ -13,6 +13,7 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.RateLimiter
@access_salt "support-request-access" @access_salt "support-request-access"
@confirmation_salt "support-request-confirmation"
@staff_topic "support:staff" @staff_topic "support:staff"
def subscribe_request(id), def subscribe_request(id),
@ -106,10 +107,14 @@ defmodule WhoNeedHelp.Support do
def get_by_access_token(id, token) when is_binary(token) do def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <- {:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000), Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: case_access_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do %SupportRequest{} = request <- Repo.get(SupportRequest, id) do
with {:ok, request} <- verify_contact(request) do if request.contact_verified_at do
{:ok, preload_conversation(request)} {:ok, preload_conversation(request)}
else
verify_legacy_confirmation(request, token)
end end
else else
_ -> {:error, :not_found} _ -> {:error, :not_found}
@ -118,6 +123,21 @@ defmodule WhoNeedHelp.Support do
def get_by_access_token(_id, _token), do: {:error, :not_found} def get_by_access_token(_id, _token), do: {:error, :not_found}
def verify_by_confirmation_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
max_age: contact_verification_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
verify_contact(request)
else
_ -> {:error, :not_found}
end
end
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
def get_for_viewer(scope, id, token \\ nil) def get_for_viewer(scope, id, token \\ nil)
def get_for_viewer(%Scope{} = scope, id, token) do def get_for_viewer(%Scope{} = scope, id, token) do
@ -130,7 +150,15 @@ defmodule WhoNeedHelp.Support do
def get_for_viewer(nil, id, token), do: get_by_access_token(id, token) def get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
def access_token(%SupportRequest{id: id}) do def access_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id) Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
max_age: case_access_max_age_seconds()
)
end
def confirmation_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
max_age: contact_verification_max_age_seconds()
)
end end
def status_url(%SupportRequest{} = request) do def status_url(%SupportRequest{} = request) do
@ -140,6 +168,13 @@ defmodule WhoNeedHelp.Support do
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}" "/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
end end
def confirmation_url(%SupportRequest{} = request) do
token = confirmation_token(request)
WhoNeedHelpWeb.Endpoint.url() <>
"/support/cases/#{request.id}/verify?token=#{URI.encode_www_form(token)}"
end
def paginate_for_staff(%Scope{user: user}, options \\ []) do def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :support_view) do if Accounts.authorized?(user, :support_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
@ -373,7 +408,7 @@ defmodule WhoNeedHelp.Support do
end end
defp notify_created({:ok, {request, :pending_verification}}) do defp notify_created({:ok, {request, :pending_verification}}) do
_ = Notifier.deliver_confirmation(request, status_url(request)) _ = Notifier.deliver_confirmation(request, confirmation_url(request))
{:ok, request} {:ok, request}
end end
@ -487,6 +522,7 @@ defmodule WhoNeedHelp.Support do
defp verify_contact(%SupportRequest{} = request), do: {:ok, request} defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request) _ = Notifier.deliver_operator_alert(request)
event = {:support_request_updated, request.id} event = {:support_request_updated, request.id}
@ -499,6 +535,28 @@ defmodule WhoNeedHelp.Support do
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request} defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
defp notify_verified_request(result), do: result defp notify_verified_request(result), do: result
defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds()
) do
{:ok, ^id} ->
with {:ok, verified} <- verify_contact(request) do
{:ok, preload_conversation(verified)}
end
_error ->
{:error, :not_found}
end
end
defp contact_verification_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
end
defp case_access_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end
defp preload_conversation(%SupportRequest{} = request) do defp preload_conversation(%SupportRequest{} = request) do
Repo.preload( Repo.preload(
request, request,

View File

@ -3,17 +3,21 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Help alias WhoNeedHelp.Help
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Help.HelpRequest alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Tracking alias WhoNeedHelp.Tracking
alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelp.Trust.{AuditEvent, RateLimiter}
@impl Oban.Worker @impl Oban.Worker
def perform(_job) do def perform(_job) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
with {:ok, expired} <- expire_available(now, 0), with {:ok, expired} <- expire_available(now, 0),
{:ok, cleanup} <- Tracking.cleanup_finished_sessions() do {:ok, cleanup} <- Tracking.cleanup_finished_sessions(),
{:ok, support_pruned} <- prune_unverified_support(verification_cutoff(now), 0),
{:ok, removal_pruned} <- prune_unverified_removal(verification_cutoff(now), 0) do
{pruned_buckets, _} = RateLimiter.prune_expired() {pruned_buckets, _} = RateLimiter.prune_expired()
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now) {pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
@ -22,6 +26,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
expired: expired, expired: expired,
tracking_positions_deleted: cleanup.positions_deleted, tracking_positions_deleted: cleanup.positions_deleted,
tracking_sessions_ended: cleanup.sessions_ended, tracking_sessions_ended: cleanup.sessions_ended,
unverified_support_pruned: support_pruned,
unverified_removal_pruned: removal_pruned,
rate_limit_buckets_pruned: pruned_buckets, rate_limit_buckets_pruned: pruned_buckets,
user_tokens_pruned: pruned_user_tokens user_tokens_pruned: pruned_user_tokens
}} }}
@ -65,4 +71,77 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
{:error, reason} {:error, reason}
end end
end end
defp prune_unverified_support(cutoff, pruned) do
result =
Repo.transact(fn ->
request =
SupportRequest
|> where(
[request],
is_nil(request.requester_id) and is_nil(request.contact_verified_at) and
request.inserted_at <= ^cutoff
)
|> order_by([request], asc: request.inserted_at, asc: request.id)
|> limit(1)
|> lock("FOR UPDATE SKIP LOCKED")
|> Repo.one()
case request do
nil ->
{:ok, :empty}
request ->
delete_target_audit("support_request", request.id)
Repo.delete(request)
end
end)
continue_pruning(result, pruned, &prune_unverified_support(cutoff, &1))
end
defp prune_unverified_removal(cutoff, pruned) do
result =
Repo.transact(fn ->
notice =
Notice
|> where(
[notice],
is_nil(notice.requester_id) and not is_nil(notice.contact_email) and
is_nil(notice.contact_verified_at) and notice.inserted_at <= ^cutoff
)
|> order_by([notice], asc: notice.inserted_at, asc: notice.id)
|> limit(1)
|> lock("FOR UPDATE SKIP LOCKED")
|> Repo.one()
case notice do
nil ->
{:ok, :empty}
notice ->
delete_target_audit("content_removal_notice", notice.id)
Repo.delete(notice)
end
end)
continue_pruning(result, pruned, &prune_unverified_removal(cutoff, &1))
end
defp continue_pruning({:ok, :empty}, pruned, _continue), do: {:ok, pruned}
defp continue_pruning({:ok, _record}, pruned, continue), do: continue.(pruned + 1)
defp continue_pruning({:error, reason}, _pruned, _continue), do: {:error, reason}
defp delete_target_audit(target_type, target_id) do
AuditEvent
|> where([event], event.target_type == ^target_type and event.target_id == ^target_id)
|> Repo.delete_all()
end
defp verification_cutoff(now) do
max_age =
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
DateTime.add(now, -max_age, :second)
end
end end

View File

@ -62,6 +62,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
end end
end end
def verify(conn, %{"id" => id, "token" => token}) do
case ContentRemoval.verify_by_confirmation_token(id, token) do
{:ok, notice} ->
conn
|> put_flash(
:info,
gettext("Your email was confirmed and the notice was sent for review.")
)
|> redirect(
to: ~p"/legal/content-removal/#{notice.id}?token=#{ContentRemoval.access_token(notice)}"
)
{:error, :not_found} ->
send_resp(conn, :not_found, "Not found")
end
end
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
defp create_notice(conn, regime, params) do defp create_notice(conn, regime, params) do
case ContentRemoval.create_notice( case ContentRemoval.create_notice(
conn.assigns.current_scope, conn.assigns.current_scope,

View File

@ -98,6 +98,23 @@ defmodule WhoNeedHelpWeb.SupportController do
end end
end end
def verify(conn, %{"id" => id, "token" => token}) do
case Support.verify_by_confirmation_token(id, token) do
{:ok, request} ->
conn
|> put_flash(
:info,
gettext("Your email was confirmed and the request was sent to support.")
)
|> redirect(to: case_path(request.id, Support.access_token(request)))
{:error, :not_found} ->
send_resp(conn, :not_found, "Not found")
end
end
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
def add_message(conn, %{"id" => id, "message" => params} = outer_params) def add_message(conn, %{"id" => id, "message" => params} = outer_params)
when is_map(params) do when is_map(params) do
token = outer_params["token"] token = outer_params["token"]

View File

@ -88,6 +88,7 @@ defmodule WhoNeedHelpWeb.Router do
get "/support/new", SupportController, :new get "/support/new", SupportController, :new
post "/support", SupportController, :create post "/support", SupportController, :create
get "/support/received", SupportController, :received get "/support/received", SupportController, :received
get "/support/cases/:id/verify", SupportController, :verify
get "/support/cases/:id", SupportController, :show get "/support/cases/:id", SupportController, :show
post "/support/cases/:id/messages", SupportController, :add_message post "/support/cases/:id/messages", SupportController, :add_message
post "/support/cases/:id/reopen", SupportController, :reopen post "/support/cases/:id/reopen", SupportController, :reopen
@ -157,6 +158,7 @@ defmodule WhoNeedHelpWeb.Router do
scope "/", WhoNeedHelpWeb do scope "/", WhoNeedHelpWeb do
pipe_through :browser pipe_through :browser
get "/legal/content-removal/:id/verify", ContentRemovalController, :verify
get "/legal/content-removal/:id", ContentRemovalController, :show get "/legal/content-removal/:id", ContentRemovalController, :show
end end

View File

@ -29,7 +29,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert_email_sent(fn email -> assert_email_sent(fn email ->
matches_request = matches_request =
email.subject =~ request.reference and email.subject =~ request.reference and
email.text_body =~ "/support/cases/#{request.id}?token=" email.text_body =~ "/support/cases/#{request.id}/verify?token="
if matches_request, do: send(test_pid, {:support_acknowledgement, email}) if matches_request, do: send(test_pid, {:support_acknowledgement, email})
matches_request matches_request
@ -37,19 +37,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert_receive {:support_acknowledgement, email} assert_receive {:support_acknowledgement, email}
[status_url] = [confirmation_url] =
Regex.run( Regex.run(
~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}\?token=[^\s]+}, ~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}/verify\?token=[^\s]+},
email.text_body email.text_body
) )
emailed_token = confirmation_token =
status_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("token") confirmation_url
|> URI.parse()
|> Map.fetch!(:query)
|> URI.decode_query()
|> Map.fetch!("token")
assert {:error, :not_found} = Support.get_by_access_token(request.id, "invalid") assert {:error, :not_found} = Support.get_by_access_token(request.id, "invalid")
assert {:error, :not_found} = Support.get_by_access_token(request.id, confirmation_token)
assert {:error, :not_found} =
Support.verify_by_confirmation_token(request.id, Support.access_token(request))
assert {:ok, verified} = assert {:ok, verified} =
Support.get_by_access_token(request.id, emailed_token) Support.verify_by_confirmation_token(request.id, confirmation_token)
verified = Repo.preload(verified, :status_events)
assert verified.contact_verified_at assert verified.contact_verified_at
assert verified.status == :open assert verified.status == :open
@ -66,6 +76,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
event.action == "support_request.contact_verified" and event.action == "support_request.contact_verified" and
event.target_id == ^request.id event.target_id == ^request.id
) )
assert_email_sent(fn received ->
received.to == [{"", request.contact_email}] and
received.subject == "Who Need Help support request #{request.reference}" and
received.text_body =~ "/support/cases/#{request.id}?token="
end)
end end
test "operator alert is sent only after public contact verification" do test "operator alert is sent only after public contact verification" do
@ -97,10 +113,16 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
refute_receive {:email, _operator_alert}, 50 refute_receive {:email, _operator_alert}, 50
assert {:ok, verified} = assert {:ok, verified} =
Support.get_by_access_token(request.id, Support.access_token(request)) Support.verify_by_confirmation_token(request.id, Support.confirmation_token(request))
assert verified.status == :open assert verified.status == :open
assert_email_sent(fn email ->
email.to == [{"", "appeal@example.com"}] and
email.subject == "Who Need Help support request #{request.reference}" and
email.text_body =~ "/support/cases/#{request.id}?token="
end)
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
email.text_body =~ "/support/operations" and email.text_body =~ "/support/operations" and
@ -191,7 +213,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
}) })
assert {:ok, verified} = assert {:ok, verified} =
Support.get_by_access_token(pending.id, Support.access_token(pending)) Support.verify_by_confirmation_token(
pending.id,
Support.confirmation_token(pending)
)
assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id] assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id]
end end
@ -432,7 +457,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
}) })
assert {:ok, verified} = assert {:ok, verified} =
ContentRemoval.get_by_access_token(notice.id, ContentRemoval.access_token(notice)) ContentRemoval.verify_by_confirmation_token(
notice.id,
ContentRemoval.confirmation_token(notice)
)
assert verified.contact_verified_at assert verified.contact_verified_at

View File

@ -3,8 +3,10 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Repo} alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Help.HelpRequest alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Trust.AuditEvent alias WhoNeedHelp.Trust.AuditEvent
alias WhoNeedHelp.Workers.ExpireRequests alias WhoNeedHelp.Workers.ExpireRequests
@ -72,4 +74,110 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{}) assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
end end
test "prunes only expired unverified public support and removal submissions" do
support_attrs = fn email, subject ->
%{
"kind" => "technical_issue",
"contact_email" => email,
"subject" => subject,
"details" => "This public support request exercises verification lifecycle cleanup."
}
end
removal_attrs = fn email, suffix ->
%{
"category" => "privacy_violation",
"submitter_name" => "Lifecycle reporter",
"contact_email" => email,
"relationship" => "self",
"content_locations" => "https://example.test/requests/lifecycle-#{suffix}",
"explanation" => "This public removal notice exercises verification lifecycle cleanup.",
"electronic_signature" => "Lifecycle reporter",
"good_faith" => "true",
"accurate_complete" => "true"
}
end
assert {:ok, expired_support} =
Support.create_request(
nil,
support_attrs.("expired-support@example.com", "Expired public support")
)
assert {:ok, fresh_support} =
Support.create_request(
nil,
support_attrs.("fresh-support@example.com", "Fresh public support")
)
assert {:ok, verified_support} =
Support.create_request(
nil,
support_attrs.("verified-support@example.com", "Verified public support")
)
assert {:ok, verified_support} =
Support.verify_by_confirmation_token(
verified_support.id,
Support.confirmation_token(verified_support)
)
assert {:ok, expired_removal} =
ContentRemoval.create_notice(
nil,
:general,
removal_attrs.("expired-removal@example.com", "expired")
)
assert {:ok, fresh_removal} =
ContentRemoval.create_notice(
nil,
:general,
removal_attrs.("fresh-removal@example.com", "fresh")
)
max_age =
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
expired_at = DateTime.add(DateTime.utc_now(:second), -(max_age + 1), :second)
Repo.update_all(
from(request in SupportRequest, where: request.id == ^expired_support.id),
set: [inserted_at: expired_at]
)
Repo.update_all(
from(notice in Notice, where: notice.id == ^expired_removal.id),
set: [inserted_at: expired_at]
)
assert {:ok,
%{
unverified_support_pruned: 1,
unverified_removal_pruned: 1
}} = ExpireRequests.perform(%Oban.Job{})
refute Repo.get(SupportRequest, expired_support.id)
refute Repo.get(Notice, expired_removal.id)
assert Repo.get(SupportRequest, fresh_support.id)
assert Repo.get(SupportRequest, verified_support.id)
assert Repo.get(Notice, fresh_removal.id)
refute Repo.exists?(
from(event in AuditEvent,
where:
event.target_type == "support_request" and
event.target_id == ^expired_support.id
)
)
refute Repo.exists?(
from(event in AuditEvent,
where:
event.target_type == "content_removal_notice" and
event.target_id == ^expired_removal.id
)
)
end
end end

View File

@ -120,6 +120,56 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert_email_sent() assert_email_sent()
end end
test "confirmation endpoints exchange short-lived confirmation links for status links", %{
conn: conn
} do
assert {:ok, support_request} =
WhoNeedHelp.Support.create_request(nil, %{
"kind" => "technical_issue",
"contact_email" => "confirmation-support@example.com",
"subject" => "Confirm the public support contact",
"details" =>
"The confirmation endpoint must redirect to a separate private status link."
})
support_conn =
get(
conn,
~p"/support/cases/#{support_request.id}/verify?token=#{WhoNeedHelp.Support.confirmation_token(support_request)}"
)
support_location = redirected_to(support_conn)
assert support_location =~ "/support/cases/#{support_request.id}?token="
refute support_location =~ "/verify"
assert Repo.get!(SupportRequest, support_request.id).contact_verified_at
assert {:ok, removal_notice} =
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
"category" => "privacy_violation",
"submitter_name" => "Confirmation reporter",
"contact_email" => "confirmation-removal@example.com",
"relationship" => "self",
"content_locations" => "https://example.test/requests/confirmation-removal",
"explanation" =>
"The removal confirmation endpoint must issue a separate status link.",
"electronic_signature" => "Confirmation reporter",
"good_faith" => "true",
"accurate_complete" => "true"
})
removal_conn =
conn
|> recycle()
|> get(
~p"/legal/content-removal/#{removal_notice.id}/verify?token=#{WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)}"
)
removal_location = redirected_to(removal_conn)
assert removal_location =~ "/legal/content-removal/#{removal_notice.id}?token="
refute removal_location =~ "/verify"
assert Repo.get!(WhoNeedHelp.ContentRemoval.Notice, removal_notice.id).contact_verified_at
end
test "removal intake enforces independent contact and client IP limits", %{conn: conn} do test "removal intake enforces independent contact and client IP limits", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies) previous = Application.get_env(:who_need_help, :rate_limit_policies)
@ -381,15 +431,15 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
}) })
assert {:ok, _verified_removal_notice} = assert {:ok, _verified_removal_notice} =
WhoNeedHelp.ContentRemoval.get_by_access_token( WhoNeedHelp.ContentRemoval.verify_by_confirmation_token(
removal_notice.id, removal_notice.id,
WhoNeedHelp.ContentRemoval.access_token(removal_notice) WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)
) )
assert {:ok, _verified_support_request} = assert {:ok, _verified_support_request} =
WhoNeedHelp.Support.get_by_access_token( WhoNeedHelp.Support.verify_by_confirmation_token(
support_request.id, support_request.id,
WhoNeedHelp.Support.access_token(support_request) WhoNeedHelp.Support.confirmation_token(support_request)
) )
staff_conn = log_in_user(conn, moderator) staff_conn = log_in_user(conn, moderator)