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

View file

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

View file

@ -43,14 +43,14 @@ defmodule Oban.Web.Crons.Helpers do
@doc """
Converts a NaiveDateTime to unix milliseconds, returning empty string for nil.
"""
def maybe_to_unix(nil), do: ""
def maybe_to_unix(timestamp) do
def maybe_to_unix(timestamp) when is_struct(timestamp) do
timestamp
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
def maybe_to_unix(_timestamp), do: ""
def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker
def show_name?(_cron), do: false
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["chain"]} icon="link" label="Chain" />
<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["structured"]} icon="table_cells" label="Structured" />
<Core.status_badge :if={@job.meta["decorated"]} icon="sparkles" label="Decorated" />
@ -502,7 +503,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
</button>
<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
id="job-edit-form"
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
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"
>
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>
</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>
"""
@ -785,14 +813,16 @@ defmodule Oban.Web.Jobs.DetailComponent do
def handle_event("save-job", params, socket) do
job = socket.assigns.job
changes =
params
|> parse_edit_params(job)
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
|> Map.new()
if can?(:update_jobs, socket.assigns.access) do
changes =
params
|> parse_edit_params(job)
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
|> Map.new()
if map_size(changes) > 0 do
send(self(), {:update_job, job, changes})
if map_size(changes) > 0 do
send(self(), {:update_job, job, changes})
end
end
{:noreply, assign(socket, edit_changed?: false)}
@ -805,12 +835,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
end
defp format_meta(%{meta: meta} = job, resolver) do
job =
if meta["recorded"] do
%{job | meta: Map.delete(meta, "return")}
else
job
end
job = %{job | meta: Map.drop(meta, ["return", "signal"])}
Resolver.call_with_fallback(resolver, :format_job_meta, [job])
end
@ -828,6 +853,41 @@ defmodule Oban.Web.Jobs.DetailComponent do
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
error =
assigns.errors

View file

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

View file

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

View file

@ -195,7 +195,11 @@ defmodule Oban.Web.CronQuery do
state: j.state,
scheduled_at: j.scheduled_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
@ -280,7 +284,10 @@ defmodule Oban.Web.CronQuery do
attempted_at: o.attempted_at,
scheduled_at: o.scheduled_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: %{
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
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
fragment(
"""
(SELECT dep->>1
FROM oban_jobs j2,
FROM ?.oban_jobs j2,
LATERAL jsonb_array_elements(j2.meta->'deps') AS dep
WHERE j2.meta->>'workflow_id' = ?
AND jsonb_typeof(dep) = 'array'
AND dep->>0 = ?
LIMIT 1)
""",
identifier(^unquote(prefix)),
unquote(sub_workflow_id),
unquote(sup_workflow_id)
)
end
end
defmacrop sub_workflow_states(parent_id) do
defmacrop sub_workflow_states(prefix, parent_id) do
quote do
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)
)
end
@ -279,18 +281,19 @@ defmodule Oban.Web.WorkflowQuery do
# Querying
def all_workflows(params, conf) do
def all_workflows(conf, params) do
limit = Map.get(params, :limit, 10)
sort_by = Map.get(params, :sort_by, "inserted")
sort_dir = Map.get(params, :sort_dir, "desc")
dir = String.to_existing_atom(sort_dir)
prefix = conf.prefix
Workflow
|> where([wf], is_nil(wf.parent_id))
|> apply_filters(params)
|> apply_sort(sort_by, dir)
|> limit(^limit)
|> select([wf], {wf, sub_workflow_states(wf.id)})
|> select([wf], {wf, sub_workflow_states(prefix, wf.id)})
|> then(&Repo.all(conf, &1))
|> Enum.map(&build_workflow/1)
end
@ -446,6 +449,8 @@ defmodule Oban.Web.WorkflowQuery do
end
defp workflow_graph_subs(conf, workflow_id) do
prefix = conf.prefix
base_query =
Job
|> where([j], has_sup_workflow_id(j.meta, ^workflow_id))
@ -464,7 +469,7 @@ defmodule Oban.Web.WorkflowQuery do
workflow_name: s.workflow_name,
sub_name: s.sub_name,
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)
end
@impl true
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal()
|> inspect(charlists: :as_lists, pretty: true)
end
@impl true
def jobs_query_limit(_state), do: 100_000
@ -138,6 +145,7 @@ defmodule Oban.Web.Resolver do
| :scale_queues
| :stop_queues
| :update_crons
| :update_jobs
@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()
@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 """
Extract the current user from a `Plug.Conn` when the dashboard mounts.
@ -283,6 +320,7 @@ defmodule Oban.Web.Resolver do
* `:scale_queues`
* `:stop_queues`
* `:update_crons`
* `:update_jobs`
Actions which aren't listed are considered disabled.
@ -411,6 +449,7 @@ defmodule Oban.Web.Resolver do
@optional_callbacks format_job_args: 1,
format_job_meta: 1,
format_recorded: 2,
format_signal: 2,
bulk_action_limit: 1,
hint_query_limit: 1,
jobs_query_limit: 1,
@ -451,6 +490,15 @@ defmodule Oban.Web.Resolver do
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
def call_with_fallback(resolver, fun, args) when is_atom(fun) and is_list(args) do
resolver =
@ -482,6 +530,13 @@ defmodule Oban.Web.Resolver do
|> inspect(@inspect_opts)
end
@doc false
def format_signal(signal, _job) do
signal
|> decode_signal()
|> inspect(@inspect_opts)
end
@doc false
def resolve_user(_conn), do: nil

View file

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