chore(deps): bump vendored oban_web from 2.12.3 to 2.12.5
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 5m5s

Fixes two vulnerabilities:
- CVE-2026-48592: Missing authorization on save-job event handler
- CVE-2026-48593: Unbounded cron range expansion causing DoS
This commit is contained in:
Graham McIntire 2026-07-30 08:30:38 -05:00
parent 079346a1b9
commit ed8bb33acb
11 changed files with 186 additions and 49 deletions

View file

@ -212,6 +212,9 @@ defmodule Oban.Web.Components.Core do
defp badge_icon(%{name: "lock_closed"} = assigns), defp badge_icon(%{name: "lock_closed"} = assigns),
do: ~H[<Icons.icon name="icon-lock-closed" class="h-4 w-4 shrink-0" />] do: ~H[<Icons.icon name="icon-lock-closed" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "signal"} = assigns),
do: ~H[<Icons.icon name="icon-signal" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "sparkles"} = assigns), defp badge_icon(%{name: "sparkles"} = assigns),
do: ~H[<Icons.icon name="icon-sparkles" class="h-4 w-4 shrink-0" />] do: ~H[<Icons.icon name="icon-sparkles" class="h-4 w-4 shrink-0" />]

View file

@ -45,10 +45,10 @@ defmodule Oban.Web.CronExpr do
defp describe_parsed(expression) do defp describe_parsed(expression) do
with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5), with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5),
{:ok, parsed_min} <- parse_field(min), {:ok, parsed_min} <- parse_field(min, 0..59),
{:ok, parsed_hrs} <- parse_field(hrs), {:ok, parsed_hrs} <- parse_field(hrs, 0..23),
{:ok, parsed_dom} <- parse_field(dom), {:ok, parsed_dom} <- parse_field(dom, 1..31),
{:ok, parsed_dow} <- parse_field(dow, @days_of_week_translations) do {:ok, parsed_dow} <- parse_field(dow, 0..7, @days_of_week_translations) do
combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow) combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow)
else else
_ -> nil _ -> nil
@ -57,11 +57,11 @@ defmodule Oban.Web.CronExpr do
# Field Parsing # Field Parsing
defp parse_field(field, translations \\ %{}) do defp parse_field(field, bounds, translations \\ %{}) do
parts = parts =
field field
|> String.split(",") |> String.split(",")
|> Enum.map(&parse_part(&1, translations)) |> Enum.map(&parse_part(&1, bounds, translations))
if Enum.any?(parts, &(&1 == :error)) do if Enum.any?(parts, &(&1 == :error)) do
:error :error
@ -70,37 +70,40 @@ defmodule Oban.Web.CronExpr do
end end
end end
defp parse_part("*", _translations), do: :wildcard defp parse_part("*", _bounds, _translations), do: :wildcard
defp parse_part("*/" <> step, _translations) do defp parse_part("*/" <> step, bounds, _translations) do
case Integer.parse(step) do case Integer.parse(step) do
{num, ""} when num > 0 -> {:step, num} {num, ""} when num > bounds.first and num <= bounds.last -> {:step, num}
_ -> :error _ -> :error
end end
end end
defp parse_part(part, translations) do defp parse_part(part, bounds, translations) do
if String.contains?(part, "-") do if String.contains?(part, "-") do
parse_range(part, translations) parse_range(part, bounds, translations)
else else
parse_value(part, translations) parse_value(part, bounds, translations)
end end
end end
defp parse_range(part, translations) do defp parse_range(part, bounds, translations) do
with [start_str, end_str] <- String.split(part, "-", parts: 2), with [start_str, end_str] <- String.split(part, "-", parts: 2),
{:ok, start_val} <- translate_or_parse(start_str, translations), {:ok, start_val} <- translate_or_parse(start_str, translations),
{:ok, end_val} <- translate_or_parse(end_str, translations) do {:ok, end_val} <- translate_or_parse(end_str, translations),
true <- start_val in bounds and end_val in bounds and end_val >= start_val do
{:range, start_val, end_val} {:range, start_val, end_val}
else else
_ -> :error _ -> :error
end end
end end
defp parse_value(part, translations) do defp parse_value(part, bounds, translations) do
case translate_or_parse(part, translations) do with {:ok, val} <- translate_or_parse(part, translations),
{:ok, val} -> {:value, val} true <- val in bounds do
:error -> :error {:value, val}
else
_ -> :error
end end
end end

View file

@ -28,7 +28,7 @@ defmodule Oban.Web.Helpers do
def oban_path(route, params) when is_list(route) do def oban_path(route, params) when is_list(route) do
route route
|> Enum.join("/") |> Enum.map_join("/", &encode_segment/1)
|> oban_path(params) |> oban_path(params)
end end
@ -50,6 +50,10 @@ defmodule Oban.Web.Helpers do
end end
end end
defp encode_segment(segment) do
segment |> to_string() |> URI.encode(&URI.char_unreserved?/1)
end
@doc """ @doc """
Toggle filterable params into a patch path. Toggle filterable params into a patch path.
""" """

View file

@ -43,14 +43,14 @@ defmodule Oban.Web.Crons.Helpers do
@doc """ @doc """
Converts a NaiveDateTime to unix milliseconds, returning empty string for nil. Converts a NaiveDateTime to unix milliseconds, returning empty string for nil.
""" """
def maybe_to_unix(nil), do: "" def maybe_to_unix(timestamp) when is_struct(timestamp) do
def maybe_to_unix(timestamp) do
timestamp timestamp
|> DateTime.from_naive!("Etc/UTC") |> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond) |> DateTime.to_unix(:millisecond)
end end
def maybe_to_unix(_timestamp), do: ""
def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker
def show_name?(_cron), do: false def show_name?(_cron), do: false
end end

View file

@ -64,6 +64,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
<Core.status_badge :if={@job.meta["chunk"]} icon="user_group" label="Chunk" /> <Core.status_badge :if={@job.meta["chunk"]} icon="user_group" label="Chunk" />
<Core.status_badge :if={@job.meta["chain"]} icon="link" label="Chain" /> <Core.status_badge :if={@job.meta["chain"]} icon="link" label="Chain" />
<Core.status_badge :if={@job.meta["recorded"]} icon="camera" label="Recorded" /> <Core.status_badge :if={@job.meta["recorded"]} icon="camera" label="Recorded" />
<Core.status_badge :if={signal_status(@job) != :none} icon="signal" label="Signal" />
<Core.status_badge :if={@job.meta["encrypted"]} icon="lock_closed" label="Encrypted" /> <Core.status_badge :if={@job.meta["encrypted"]} icon="lock_closed" label="Encrypted" />
<Core.status_badge :if={@job.meta["structured"]} icon="table_cells" label="Structured" /> <Core.status_badge :if={@job.meta["structured"]} icon="table_cells" label="Structured" />
<Core.status_badge :if={@job.meta["decorated"]} icon="sparkles" label="Decorated" /> <Core.status_badge :if={@job.meta["decorated"]} icon="sparkles" label="Decorated" />
@ -502,7 +503,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
</button> </button>
<div id="edit-content" class={["mt-3", if(executing?(@job), do: "hidden")]}> <div id="edit-content" class={["mt-3", if(executing?(@job), do: "hidden")]}>
<fieldset disabled={executing?(@job)}> <fieldset disabled={executing?(@job) or not can?(:update_jobs, @access)}>
<form <form
id="job-edit-form" id="job-edit-form"
class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4" class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
@ -570,7 +571,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
</button> </button>
<button <button
type="submit" type="submit"
disabled={not @edit_changed?} disabled={not @edit_changed? or not can?(:update_jobs, @access)}
class="px-6 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" class="px-6 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
> >
Save Changes Save Changes
@ -701,6 +702,33 @@ defmodule Oban.Web.Jobs.DetailComponent do
<pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_recorded(@job, @resolver)}</pre> <pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_recorded(@job, @resolver)}</pre>
</div> </div>
</div> </div>
<div :if={signal_status(@job) != :none} class="mt-4">
<div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
{signal_heading(@job)}
</h4>
<.pro_badge id="signal-pro-badge" tooltip="Awaitable signal from Oban.Pro.Worker" />
</div>
<button
type="button"
id="copy-signal"
class={[
"w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer",
signal_status(@job) != :received && "invisible"
]}
data-title="Copy to clipboard"
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_signal(@job, @resolver))}
>
<Icons.icon name="icon-clipboard" class="w-4 h-4" />
</button>
</div>
<pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_signal(@job, @resolver)}</pre>
</div>
</div>
</div> </div>
</div> </div>
""" """
@ -785,14 +813,16 @@ defmodule Oban.Web.Jobs.DetailComponent do
def handle_event("save-job", params, socket) do def handle_event("save-job", params, socket) do
job = socket.assigns.job job = socket.assigns.job
changes = if can?(:update_jobs, socket.assigns.access) do
params changes =
|> parse_edit_params(job) params
|> Enum.reject(fn {_key, val} -> is_nil(val) end) |> parse_edit_params(job)
|> Map.new() |> Enum.reject(fn {_key, val} -> is_nil(val) end)
|> Map.new()
if map_size(changes) > 0 do if map_size(changes) > 0 do
send(self(), {:update_job, job, changes}) send(self(), {:update_job, job, changes})
end
end end
{:noreply, assign(socket, edit_changed?: false)} {:noreply, assign(socket, edit_changed?: false)}
@ -805,12 +835,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
end end
defp format_meta(%{meta: meta} = job, resolver) do defp format_meta(%{meta: meta} = job, resolver) do
job = job = %{job | meta: Map.drop(meta, ["return", "signal"])}
if meta["recorded"] do
%{job | meta: Map.delete(meta, "return")}
else
job
end
Resolver.call_with_fallback(resolver, :format_job_meta, [job]) Resolver.call_with_fallback(resolver, :format_job_meta, [job])
end end
@ -828,6 +853,41 @@ defmodule Oban.Web.Jobs.DetailComponent do
end end
end end
defp format_signal(%{meta: %{"signal" => value}} = job, resolver) do
Resolver.call_with_fallback(resolver, :format_signal, [value, job])
end
defp format_signal(%{meta: %{"wait_until" => wait_until}}, _resolver) do
case wait_until_to_datetime(wait_until) do
{:ok, datetime} -> "Deadline #{Timing.datetime_to_words(datetime)}"
:infinity -> "No deadline"
:error -> ""
end
end
defp signal_status(%{meta: %{"signal" => _}}), do: :received
defp signal_status(%{meta: %{"wait_until" => _}}), do: :awaiting
defp signal_status(_), do: :none
defp signal_heading(job) do
case signal_status(job) do
:received -> "Received Signal"
:awaiting -> "Awaiting Signal"
:none -> "Signal"
end
end
defp wait_until_to_datetime("infinity"), do: :infinity
defp wait_until_to_datetime(ms) when is_integer(ms) do
case DateTime.from_unix(ms, :millisecond) do
{:ok, datetime} -> {:ok, datetime |> DateTime.to_naive() |> NaiveDateTime.truncate(:second)}
_ -> :error
end
end
defp wait_until_to_datetime(_), do: :error
defp error_entry(assigns) do defp error_entry(assigns) do
error = error =
assigns.errors assigns.errors

View file

@ -273,7 +273,7 @@ defmodule Oban.Web.Jobs.NewComponent do
defp parse_scheduled_at(str) when is_binary(str) do defp parse_scheduled_at(str) when is_binary(str) do
case DateTime.from_iso8601(str <> ":00Z") do case DateTime.from_iso8601(str <> ":00Z") do
{:ok, datetime, _offset} -> datetime {:ok, datetime} -> datetime
_ -> nil _ -> nil
end end
end end

View file

@ -211,7 +211,7 @@ defmodule Oban.Web.WorkflowsPage do
) )
true -> true ->
workflows = WorkflowQuery.all_workflows(params, conf) workflows = WorkflowQuery.all_workflows(conf, params)
limit = params.limit limit = params.limit
assign(socket, assign(socket,

View file

@ -195,7 +195,11 @@ defmodule Oban.Web.CronQuery do
state: j.state, state: j.state,
scheduled_at: j.scheduled_at, scheduled_at: j.scheduled_at,
attempted_at: j.attempted_at, attempted_at: j.attempted_at,
finished_at: fragment("COALESCE(?, ?, ?)", j.completed_at, j.cancelled_at, j.discarded_at) finished_at:
type(
fragment("COALESCE(?, ?, ?)", j.completed_at, j.cancelled_at, j.discarded_at),
:utc_datetime_usec
)
}) })
conf conf
@ -280,7 +284,10 @@ defmodule Oban.Web.CronQuery do
attempted_at: o.attempted_at, attempted_at: o.attempted_at,
scheduled_at: o.scheduled_at, scheduled_at: o.scheduled_at,
finished_at: finished_at:
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at) type(
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at),
:utc_datetime_usec
)
}, },
select_merge: %{ select_merge: %{
rn: over(row_number(), partition_by: o.meta["cron_name"], order_by: [desc: o.id]) rn: over(row_number(), partition_by: o.meta["cron_name"], order_by: [desc: o.id])

View file

@ -126,30 +126,32 @@ defmodule Oban.Web.WorkflowQuery do
end end
end end
defmacrop sub_workflow_parent_dep(sub_workflow_id, sup_workflow_id) do defmacrop sub_workflow_parent_dep(prefix, sub_workflow_id, sup_workflow_id) do
quote do quote do
fragment( fragment(
""" """
(SELECT dep->>1 (SELECT dep->>1
FROM oban_jobs j2, FROM ?.oban_jobs j2,
LATERAL jsonb_array_elements(j2.meta->'deps') AS dep LATERAL jsonb_array_elements(j2.meta->'deps') AS dep
WHERE j2.meta->>'workflow_id' = ? WHERE j2.meta->>'workflow_id' = ?
AND jsonb_typeof(dep) = 'array' AND jsonb_typeof(dep) = 'array'
AND dep->>0 = ? AND dep->>0 = ?
LIMIT 1) LIMIT 1)
""", """,
identifier(^unquote(prefix)),
unquote(sub_workflow_id), unquote(sub_workflow_id),
unquote(sup_workflow_id) unquote(sup_workflow_id)
) )
end end
end end
defmacrop sub_workflow_states(parent_id) do defmacrop sub_workflow_states(prefix, parent_id) do
quote do quote do
fragment( fragment(
""" """
(SELECT COALESCE(array_agg(state), '{}') FROM oban_workflows WHERE parent_id = ?) (SELECT COALESCE(array_agg(state), '{}') FROM ?.oban_workflows WHERE parent_id = ?)
""", """,
identifier(^unquote(prefix)),
unquote(parent_id) unquote(parent_id)
) )
end end
@ -279,18 +281,19 @@ defmodule Oban.Web.WorkflowQuery do
# Querying # Querying
def all_workflows(params, conf) do def all_workflows(conf, params) do
limit = Map.get(params, :limit, 10) limit = Map.get(params, :limit, 10)
sort_by = Map.get(params, :sort_by, "inserted") sort_by = Map.get(params, :sort_by, "inserted")
sort_dir = Map.get(params, :sort_dir, "desc") sort_dir = Map.get(params, :sort_dir, "desc")
dir = String.to_existing_atom(sort_dir) dir = String.to_existing_atom(sort_dir)
prefix = conf.prefix
Workflow Workflow
|> where([wf], is_nil(wf.parent_id)) |> where([wf], is_nil(wf.parent_id))
|> apply_filters(params) |> apply_filters(params)
|> apply_sort(sort_by, dir) |> apply_sort(sort_by, dir)
|> limit(^limit) |> limit(^limit)
|> select([wf], {wf, sub_workflow_states(wf.id)}) |> select([wf], {wf, sub_workflow_states(prefix, wf.id)})
|> then(&Repo.all(conf, &1)) |> then(&Repo.all(conf, &1))
|> Enum.map(&build_workflow/1) |> Enum.map(&build_workflow/1)
end end
@ -446,6 +449,8 @@ defmodule Oban.Web.WorkflowQuery do
end end
defp workflow_graph_subs(conf, workflow_id) do defp workflow_graph_subs(conf, workflow_id) do
prefix = conf.prefix
base_query = base_query =
Job Job
|> where([j], has_sup_workflow_id(j.meta, ^workflow_id)) |> where([j], has_sup_workflow_id(j.meta, ^workflow_id))
@ -464,7 +469,7 @@ defmodule Oban.Web.WorkflowQuery do
workflow_name: s.workflow_name, workflow_name: s.workflow_name,
sub_name: s.sub_name, sub_name: s.sub_name,
state: s.state, state: s.state,
parent_dep: sub_workflow_parent_dep(s.workflow_id, ^workflow_id) parent_dep: sub_workflow_parent_dep(prefix, s.workflow_id, ^workflow_id)
} }
) )

View file

@ -41,6 +41,13 @@ defmodule Oban.Web.Resolver do
|> inspect(charlists: :as_lists, pretty: true) |> inspect(charlists: :as_lists, pretty: true)
end end
@impl true
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal()
|> inspect(charlists: :as_lists, pretty: true)
end
@impl true @impl true
def jobs_query_limit(_state), do: 100_000 def jobs_query_limit(_state), do: 100_000
@ -138,6 +145,7 @@ defmodule Oban.Web.Resolver do
| :scale_queues | :scale_queues
| :stop_queues | :stop_queues
| :update_crons | :update_crons
| :update_jobs
@type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers @type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers
@ -234,6 +242,35 @@ defmodule Oban.Web.Resolver do
""" """
@callback format_recorded(recorded :: term(), job :: Job.t()) :: iodata() @callback format_recorded(recorded :: term(), job :: Job.t()) :: iodata()
@doc """
Customize the formatting of awaitable signal payloads wherever they are displayed.
This callback is similar to `c:format_recorded/2`, but it accepts both the signal binary and
the job to help augment the output.
Note that you **must decode the signal binary** prior to inspecting it.
## Examples
Disable pretty printing and change the output width to 98 characters:
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal()
|> inspect(pretty: false, width: 98)
end
Decode the signal value without the `:safe` flag set, to allow decoding terms with unknown
atoms:
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal([])
|> inspect(pretty: false, width: 98)
end
"""
@callback format_signal(signal :: term(), job :: Job.t()) :: iodata()
@doc """ @doc """
Extract the current user from a `Plug.Conn` when the dashboard mounts. Extract the current user from a `Plug.Conn` when the dashboard mounts.
@ -283,6 +320,7 @@ defmodule Oban.Web.Resolver do
* `:scale_queues` * `:scale_queues`
* `:stop_queues` * `:stop_queues`
* `:update_crons` * `:update_crons`
* `:update_jobs`
Actions which aren't listed are considered disabled. Actions which aren't listed are considered disabled.
@ -411,6 +449,7 @@ defmodule Oban.Web.Resolver do
@optional_callbacks format_job_args: 1, @optional_callbacks format_job_args: 1,
format_job_meta: 1, format_job_meta: 1,
format_recorded: 2, format_recorded: 2,
format_signal: 2,
bulk_action_limit: 1, bulk_action_limit: 1,
hint_query_limit: 1, hint_query_limit: 1,
jobs_query_limit: 1, jobs_query_limit: 1,
@ -451,6 +490,15 @@ defmodule Oban.Web.Resolver do
end end
end end
@doc """
Decode an awaitable signal payload from a compressed, base64 binary into proper terms.
Signals share the same wire format as recorded output, so this is a thin alias around
`decode_recorded/2` provided for documentation symmetry.
"""
@spec decode_signal(binary(), [:safe]) :: term()
def decode_signal(bin, opts \\ [:safe]), do: decode_recorded(bin, opts)
@doc false @doc false
def call_with_fallback(resolver, fun, args) when is_atom(fun) and is_list(args) do def call_with_fallback(resolver, fun, args) when is_atom(fun) and is_list(args) do
resolver = resolver =
@ -482,6 +530,13 @@ defmodule Oban.Web.Resolver do
|> inspect(@inspect_opts) |> inspect(@inspect_opts)
end end
@doc false
def format_signal(signal, _job) do
signal
|> decode_signal()
|> inspect(@inspect_opts)
end
@doc false @doc false
def resolve_user(_conn), do: nil def resolve_user(_conn), do: nil

View file

@ -2,7 +2,7 @@ defmodule Oban.Web.MixProject do
use Mix.Project use Mix.Project
@source_url "https://github.com/oban-bg/oban_web" @source_url "https://github.com/oban-bg/oban_web"
@version "2.12.3" @version "2.12.5"
def project do def project do
[ [