Skip to content

Closes #22872: Validate custom script Meta values before enqueueing - #23068

Merged
pheus merged 6 commits into
mainfrom
22872-invalid-script-meta-500
Sep 1, 2026
Merged

pheus merged 6 commits into
mainfrom
22872-invalid-script-meta-500

Conversation

@jnovinger

Copy link
Copy Markdown
Member

Closes: #22872

A custom script could declare an invalid job_timeout or notifications_default in its Meta class. Both values reached the job unvalidated: a bad notifications_default failed Job.full_clean() and a bad job_timeout failed in RQ, each raising an unhandled exception that surfaced as an HTTP 500.

Validation now happens once, at the ScriptJob.enqueue choke point, through a new BaseScript.validate_meta() classmethod. Every way of running a script funnels through ScriptJob.enqueue (interactive runs, the REST API, the runscript command, event-rule actions, and recurring reschedules), so a misconfigured script is caught before any job is created and each caller reports the error in its own idiom:

  • UI: the run form re-renders with an error message
  • REST API: a 400 response
  • runscript command: a CommandError instead of a traceback
  • Event-rule action: logged and skipped, so a misconfigured script cannot abort the object change that triggered it
  • Recurring reschedule: logged against the completed job without altering its outcome (the run that just finished keeps its status and does not fire a duplicate notification)

Unset values keep their existing valid defaults, so scripts that run today are unaffected. Non-positive job_timeout values are also rejected: they parse cleanly but are nonsensical as a timeout.

A custom script could declare an invalid job_timeout or notifications_default
in its Meta class. Both values were passed through to the job unvalidated: an
invalid notifications_default reached Job.full_clean() and an invalid job_timeout
reached RQ, each raising an unhandled exception that surfaced as an HTTP 500.

Validation now happens once at the ScriptJob.enqueue choke point, via a new
BaseScript.validate_meta() classmethod. Because every way of running a script
funnels through ScriptJob.enqueue (interactive runs, the REST API, the runscript
command, event-rule actions, and recurring reschedules), a misconfigured script
is caught before any job is created and each caller surfaces the error in its own
idiom: the UI re-renders the form with a message, the REST API returns a 400, the
runscript command raises a CommandError, an event-rule action is logged and
skipped so it cannot abort the triggering object change, and a recurring
reschedule is logged against the completed job without altering its outcome.

Unset values keep their existing valid defaults and are not rejected, so scripts
that run today are unaffected.
@jnovinger

Copy link
Copy Markdown
Member Author

@claude /review Only report on issues of potential concern; we don't need commentary on anything that doesn't require further consideration.

@jnovinger
jnovinger marked this pull request as draft August 28, 2026 14:00
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jnovinger's task in 6m 57s —— View job


Review: #23068 — Validate custom script Meta values before enqueueing

  • Gather PR context and diff
  • Review validate_meta() implementation
  • Review call sites (UI, API, runscript, events, reschedule)
  • Review test coverage
  • Post review

The choke-point approach is sound — ScriptJob.enqueue really is the single funnel, and each caller's error idiom is appropriate. Findings below, roughly by severity.


1. Validating Meta instead of the values actually enqueued causes a behavior regression

validate_meta() reads cls.notifications_default / cls.job_timeout off the class, but the value that actually reaches Job comes from the caller's kwargs:

  • API: ScriptInputSerializer.notificationsMeta.notifications_default is only used as the field default (netbox/extras/api/serializers_/scripts.py). If the client sends an explicit notifications, the invalid default is never used.
  • UI: _notifications is a ChoiceField with valid choices; as_form() only sets .initial (netbox/extras/scripts.py:573). An invalid initial doesn't match any <option>, so the browser submits the first valid choice and ScriptForm.clean()'s initial-fallback never fires.

So a script with notifications_default = 'on_error' runs fine today through both the UI dropdown and an explicit-API request, and after this PR it hard-fails with a form error / HTTP 400. Only the "quick run" path (no _notifications submitted) and the serializer-default path are actually broken today — which is exactly what the two new tests exercise.

The inverse gap exists too: a caller-supplied job_timeout that isn't from Meta still bypasses validation and blows up in the transaction.on_commit callback in Job.enqueue.

Validating the effective values would close both:

# netbox/extras/jobs.py
script_class.validate_meta(
    job_timeout=kwargs.get('job_timeout', script_class.job_timeout),
    notifications=kwargs.get('notifications'),  # None → fall back to notifications_default
)

If rejecting misconfigured scripts unconditionally is the intended (stricter) behavior, that's defensible — but it's a behavior change beyond the 500 fix and worth being explicit about in the release note. Fix this →

2. ScriptJob.enqueue narrows the base signature

netbox/extras/jobs.py:31def enqueue(cls, instance, *args, **kwargs) makes instance a required positional, where JobRunner.enqueue is (cls, *args, **kwargs). JobRunner.enqueue_once() calls:

return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs)

With any positional *args that's TypeError: got multiple values for argument 'instance'. Not reachable for ScriptJob today, but it's a latent trap for plugin subclasses. Keyword-only preserves the contract:

def enqueue(cls, *args, instance=None, **kwargs):
    ...
    return super().enqueue(*args, instance=instance, **kwargs)

3. Event-rule failures leave no trace outside the log

netbox/extras/events.py:271 — skipping the enqueue means no Job row is created, so nothing appears in the Jobs list or on the script's page. An operator whose event rule silently stops firing has only netbox.events_processor logs to go on. Creating an ERRORED Job (how other script failures surface) would be more discoverable. Deliberate trade-off either way, but worth a maintainer call.

4. runscript CommandError branch is untested

netbox/extras/management/commands/runscript.py:110 adds an except ValidationError → CommandError, but netbox/extras/tests/test_management_commands.py isn't touched. Every other caller got a test; this one is the odd one out.

5. Release note entry

AGENTS.md: "Do not include an entry in the release notes for the PR unless explicitly instructed to do so." This PR adds an entry and opens a new ## v4.6.10 section in docs/release-notes/version-4.6.md.

Minor

  • netbox/extras/scripts.py:423AssertionError in the except tuple appears to be dead: rq 2.11's parse_timeout has no assert, only int() (TypeError) and an explicit TimeoutFormatError. The comment ("its internal int()/assert") reads as inaccurate. Also, job_timeout = True passes validation (bool is numbers.Integral, True > 0).
  • netbox/extras/api/views.py:424raise ValidationError(e.messages) renders as a bare JSON array (["Invalid job_timeout…"]) rather than NetBox's usual object-shaped error body. {'detail': e.messages} or {api_settings.NON_FIELD_ERRORS_KEY: e.messages} would be more consistent; raise … from e preserves the chain.
  • netbox/netbox/jobs.py:8 — the message built with gettext_lazy only ever lands in logger.error() and the job log; the surrounding log statements in this module aren't translated.

Correctness of the core fix looks right otherwise: validation runs before any Job row is created (previously the job_timeout failure happened inside the on_commit callback, leaving an orphaned PENDING job), parse_timeout/TimeoutFormatError imports match rq 2.11, and the reschedule handler correctly avoids re-running terminate()Job.log() only mutates log_entries in memory, so the added job.save() is required.

I did not run the test suite (no DB/Redis in this environment).
• branch 22872-invalid-script-meta-500

Validate the execution parameters actually being enqueued rather than only the
script's Meta defaults, so an explicit job_timeout or notifications supplied by a
caller (for example via the REST API) is checked and a valid explicit value is no
longer rejected because of an unused Meta default.

Make the instance argument to ScriptJob.enqueue keyword-only so the override
preserves JobRunner.enqueue's signature and cannot collide with a positional
argument in enqueue_once().

Return the REST API validation error under the non-field detail key instead of a
bare list, and chain the original exception.

Add a runscript management command test covering the invalid-Meta path.

Drop the release-note entry, which does not belong in a bug-fix PR.
@jnovinger

Copy link
Copy Markdown
Member Author

Validating Meta instead of the values actually enqueued causes a behavior regression

Fixed in 4330376. validate_meta() now takes the effective job_timeout and notifications and only falls back to the Meta default when the caller omits them, so an explicit value is validated and a valid one is no longer rejected over an unused default.

ScriptJob.enqueue narrows the base signature

Fixed in 4330376: instance is keyword-only now, so the override keeps JobRunner.enqueue's signature and can't collide with a positional in enqueue_once().

Event-rule failures leave no trace outside the log

Keeping log-and-continue here to match the existing pipeline: flush_events logs and continues, and the webhook branch doesn't validate at all. An ERRORED Job would be more discoverable, but my view is that belongs in a follow-up applied uniformly across event actions rather than the script branch alone.

runscript CommandError branch is untested

Added a test in 4330376.

Release note entry

Removed in 4330376. Whoops, let Claude get that one by me.

AssertionError in the except tuple appears to be dead

parse_timeout in rq 2.11 still runs assert isinstance(timeout, str) after the int() ValueError path, so the catch is defensive rather than dead. Keeping it.

raise ValidationError(e.messages) renders as a bare JSON array

Fixed in 4330376: raised under the detail key and chained with from e.

job_timeout = True passes validation

True is numbers.Integral and parses to 1, which is harmless as a timeout, so I'm leaving it rather than special-casing bools.

@jnovinger
jnovinger marked this pull request as ready for review August 28, 2026 14:34
@jnovinger
jnovinger requested review from a team and pheus and removed request for a team August 28, 2026 14:34
The new event-rule test triggered on Site, whose object type is already covered
by the class-level event rules in setUpTestData. Under the parallel test runner
the extra Site update enqueued a job into the shared RQ queue that another test
counted, making test_single_update_process_eventrule intermittently see two
queued jobs instead of one.

Trigger the test on Manufacturer instead, which no class-level rule targets, and
assert the queue stays empty so the test cannot leak a job to a sibling.

@pheus pheus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

I found one remaining compatibility issue that I think needs to be addressed before merge: positional instance calls are still broken. I’ve left an inline comment with the details and a suggested regression test.

Comment thread netbox/extras/jobs.py Outdated
The keyword-only instance parameter broke positional calls: JobRunner.enqueue
forwards the first positional argument to Job.enqueue as instance, so
ScriptJob.enqueue(script) sent instance both positionally and as instance=None,
raising a TypeError.

Keep the inherited (*args, **kwargs) signature and resolve the instance for
validation without consuming it, forwarding the original arguments to super()
unchanged. Add regression tests covering both the positional and keyword forms.
@pheus

pheus commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This looks like another shared-RQ-queue race rather than a rollback failure. The two successful ScriptJob.enqueue() tests execute their on-commit callbacks against the real default queue, leaving one Redis job each. Under the parallel runner, this event-rule test can then observe both jobs and fail with 2 != 0.

Could we keep those callbacks unexecuted and assert that they were registered instead? That should retain the forwarding coverage without allowing these tests to affect another worker's queue.

Test classes using RQQueueTestMixin share one Redis instance. The parallel test
runner isolates the database per worker but not Redis, so classes that enqueue
jobs and assert exact queue counts race each other across workers: one worker's
enqueues and flushall() perturb another worker's count. This surfaced as
intermittent AssertionError on queue.count in EventRuleTestCase, with the failing
test varying by environment.

Mix SerializeMixin into RQQueueTestMixin so classes sharing the queue hold an
exclusive lock and never run concurrently. This is Django's documented mechanism
for test classes that share a single external resource under the parallel runner.
ScriptJobEnqueueValidationTestCase is a plain TestCase with no RQQueueTestMixin,
so it never clears the RQ queue. Two of its tests executed the on_commit callback
that pushes a real ScriptJob into the shared Redis queue, and under the parallel
runner that leaked job was read by a concurrent EventRuleTestCase.test_send_webhook
(send_webhook(**job.kwargs) raised TypeError on the ScriptJob's job= kwarg).

These tests assert Job-row creation, which Job.enqueue() does via save() before
registering the RQ push, so they don't need the push to fire. Drop execute=True
from captureOnCommitCallbacks so the callback is captured but never run: the Job
row is still created and asserted, and nothing enters the shared queue. End-to-end
enqueue-to-RQ coverage remains in EventRuleTestCase, which clears the queue.
@jnovinger
jnovinger requested a review from pheus September 1, 2026 14:27
@jnovinger

Copy link
Copy Markdown
Member Author

Could we keep those callbacks unexecuted and assert that they were registered instead? That should retain the forwarding coverage without allowing these tests to affect another worker's queue.

This (plus the test serialization of tests in that class) was the answer. Thanks!

@pheus pheus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update! This looks good to me!

@pheus
pheus merged commit a39d562 into main Sep 1, 2026
18 checks passed
@pheus
pheus deleted the 22872-invalid-script-meta-500 branch September 1, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid Custom Script Meta values raise unhandled exceptions

2 participants