1.3.1 — address CodeQL alerts from initial scan

Patch release covering the security findings from the repo's first
CodeQL scan against 1.3.0:

- Sanitise params[:path] before it flows into form action / link href
  in submissions_filters/_filter_modal (reflected XSS).
- Slice required_params to email/phone before passing to find_by! /
  find_or_initialize_by in start_form_controller (column-name
  injection via template-owner-controlled link_form_fields preference).
- Rewrite FULL_EMAIL_REGEXP local-part to remove the nested quantifier
  (ReDoS).
- Replace the Bearer-token regex in mcp_controller with a string
  prefix check (polynomial ReDoS).
- Swap Math.random()-based attachment UUIDs for crypto.randomUUID()
  in the submission-form Vue dropzone / signature / initials steps.
- Add a workflow-level permissions: read-all block to ci.yml.

See CHANGELOG.md [1.3.1] for the full per-alert breakdown and the
list of CodeQL findings that are false positives in context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/687/head
Wabo 1 month ago
parent 34250ac305
commit 5433aa4dc3

@ -1,6 +1,7 @@
--- ---
name: CI name: CI
on: [push] on: [push]
permissions: read-all
jobs: jobs:
rebrand_check: rebrand_check:

@ -4,6 +4,28 @@ All notable changes to WaboSign are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.1] — 2026-05-20
Security-focused patch addressing the alerts surfaced by the repo's first CodeQL scan (run against the 1.3.0 tag, commit [34250ac3](https://github.com/wabolabs/wabosign/commit/34250ac3)). No functional changes.
### Security
- [app/views/submissions_filters/_filter_modal.html.erb](app/views/submissions_filters/_filter_modal.html.erb) — reflected XSS (`rb/reflected-xss`): `params[:path]` flowed unsanitised into both the form `action` and the "remove filter" link `href`. Now constrained via a `filter_path` local that defaults to `/` unless the supplied value starts with `/`, blocking `javascript:` and absolute-URL payloads.
- [app/controllers/start_form_controller.rb](app/controllers/start_form_controller.rb) — column-name injection (`rb/sql-injection`, two sites): `find_by!` / `find_or_initialize_by` were keyed by `required_params.except('name')`, whose keys derive from the template-owner-controlled `link_form_fields` preference. Replaced with `required_params.slice('email', 'phone')` so only the columns actually permitted by `submitter_params` can reach the SQL builder.
- [app/models/user.rb](app/models/user.rb) — ReDoS (`rb/redos`): the local-part of `FULL_EMAIL_REGEXP` used a nested quantifier (`(?:(?:[a-z0-9_-]+[.+'])*[a-z0-9_-]+)*`) that backtracks exponentially on adversarial input. Rewritten as `[a-z0-9_]+(?:[.'+\-][a-z0-9_]+)*` — same accepted set, linear matching.
- [app/controllers/mcp_controller.rb](app/controllers/mcp_controller.rb) — polynomial ReDoS (`rb/polynomial-redos`): Bearer-token extraction used `\ABearer\s+(.+)\z`, which CodeQL flags as polynomial on long Authorization headers. Replaced with a `start_with?('Bearer ')` check plus a string slice.
- [app/javascript/submission_form/dropzone.vue](app/javascript/submission_form/dropzone.vue), [initials_step.vue](app/javascript/submission_form/initials_step.vue), [signature_step.vue](app/javascript/submission_form/signature_step.vue) — insecure randomness (`js/insecure-randomness`): attachment-correlation UUIDs were generated with `Math.random().toString()`. Swapped to `crypto.randomUUID()`. The IDs are UI-only, but the change matches the secure default and clears the alerts.
- [.github/workflows/ci.yml](.github/workflows/ci.yml) — missing-workflow-permissions (`actions/missing-workflow-permissions`, six jobs): added a single workflow-level `permissions: read-all` block. All six CI jobs are read-only (lint/test/scan); none publish artefacts or post statuses that need write access.
### Notes
- The following CodeQL alerts on the 1.3.0 commit are false positives in context and are not addressed by this release; they should be dismissed in the GitHub Security tab:
- `rb/insecure-mass-assignment` on the five settings controllers (`user_configs`, `storage_settings`, `email_smtp_settings`, `account_configs`, `account_custom_fields`) — every call site uses `params.require(...).permit(...)` strong-parameters before `update!`.
- `rb/csrf-protection-disabled` on `users/omniauth_callbacks_controller.rb` (OAuth provider callbacks legitimately can't carry a CSRF token) and `send_submission_email_controller.rb` (intentional public endpoint, rate-limited).
- `rb/weak-sensitive-data-hashing` on `preview_document_page_controller.rb`, `config/dotenv.rb`, `lib/puma/plugin/redis_server.rb` — SHA-1 is used only as a non-cryptographic identifier (tempfile path, cache key) and is not protecting sensitive data.
- `rb/clear-text-storage-sensitive-data` on `sso_settings_controller.rb` — the target column is on [`EncryptedConfig`](app/models/encrypted_config.rb), which declares `encrypts :value`, so the SSO `client_secret` is stored encrypted at rest.
- Released image: `ghcr.io/wabolabs/wabosign:1.3.1` (also tagged `:latest`).
[1.3.1]: https://github.com/wabolabs/wabosign/releases/tag/1.3.1
## [1.3.0] — 2026-05-19 ## [1.3.0] — 2026-05-19
Adds three new SMS providers alongside the existing BulkVS integration. Adds three new SMS providers alongside the existing BulkVS integration.

@ -40,7 +40,7 @@ WaboSign is a fork of [DocuSeal](https://github.com/docusealco/docuseal) under A
## Docker ## Docker
```sh ```sh
docker run --name wabosign -p 3000:3000 -v .:/data ghcr.io/wabolabs/wabosign:1.3.0 docker run --name wabosign -p 3000:3000 -v .:/data ghcr.io/wabolabs/wabosign:1.3.1
``` ```
`:latest` always tracks the most recent release; pin a `MAJOR.MINOR.PATCH` tag for reproducible deployments. `:latest` always tracks the most recent release; pin a `MAJOR.MINOR.PATCH` tag for reproducible deployments.
@ -61,8 +61,8 @@ WaboSign ships with email + password (Devise) and TOTP two-factor auth out of th
## Releases ## Releases
- **Current release:** 1.3.0 — see [CHANGELOG.md](CHANGELOG.md). - **Current release:** 1.3.1 — see [CHANGELOG.md](CHANGELOG.md).
- **Container image:** `ghcr.io/wabolabs/wabosign:1.3.0` (or `:latest`). - **Container image:** `ghcr.io/wabolabs/wabosign:1.3.1` (or `:latest`).
- **Versioning:** `MAJOR.MINOR.PATCH` per [semver.org](https://semver.org). - **Versioning:** `MAJOR.MINOR.PATCH` per [semver.org](https://semver.org).
- **Tagging triggers a build:** pushing a `MAJOR.MINOR.PATCH` git tag runs [`.github/workflows/docker.yml`](.github/workflows/docker.yml), which builds `linux/amd64` + `linux/arm64` and pushes to GHCR. - **Tagging triggers a build:** pushing a `MAJOR.MINOR.PATCH` git tag runs [`.github/workflows/docker.yml`](.github/workflows/docker.yml), which builds `linux/amd64` + `linux/arm64` and pushes to GHCR.

@ -47,7 +47,8 @@ class McpController < ActionController::API
end end
def user_from_api_key def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] auth = request.headers['Authorization'].to_s
token = auth.start_with?('Bearer ') ? auth[7..].strip.presence : nil
return if token.blank? return if token.blank?

@ -82,7 +82,7 @@ class StartFormController < ApplicationController
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.find_by!(required_params.except('name')) .find_by!(required_params.slice('email', 'phone'))
end end
private private
@ -120,7 +120,7 @@ class StartFormController < ApplicationController
required_params = required_fields.index_with { |key| submitter_params[key] } required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name') find_params = required_params.slice('email', 'phone')
submitter = Submitter.new if find_params.compact_blank.blank? submitter = Submitter.new if find_params.compact_blank.blank?

@ -147,7 +147,7 @@ export default {
reader.onloadend = () => { reader.onloadend = () => {
resolve({ resolve({
url: reader.result, url: reader.result,
uuid: Math.random().toString(), uuid: crypto.randomUUID(),
filename: file.name filename: file.name
}) })
} }

@ -412,7 +412,7 @@ export default {
reader.readAsDataURL(file) reader.readAsDataURL(file)
reader.onloadend = () => { reader.onloadend = () => {
const attachment = { url: reader.result, uuid: Math.random().toString() } const attachment = { url: reader.result, uuid: crypto.randomUUID() }
this.$emit('attached', attachment) this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)

@ -910,7 +910,7 @@ export default {
reader.readAsDataURL(file) reader.readAsDataURL(file)
reader.onloadend = () => { reader.onloadend = () => {
const attachment = { url: reader.result, uuid: Math.random().toString() } const attachment = { url: reader.result, uuid: crypto.randomUUID() }
this.$emit('attached', attachment) this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)

@ -56,7 +56,7 @@ class User < ApplicationRecord
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
FULL_EMAIL_REGEXP = FULL_EMAIL_REGEXP =
/\A[a-z0-9][.']?(?:(?:[a-z0-9_-]+[.+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[.-])*[a-z0-9]+\.[a-z]{2,}\z/i /\A[a-z0-9_]+(?:[.'+\-][a-z0-9_]+)*@(?:[a-z0-9]+[.\-])*[a-z0-9]+\.[a-z]{2,}\z/i
has_one_attached :signature has_one_attached :signature
has_one_attached :initials has_one_attached :initials

@ -1,5 +1,6 @@
<% filter_path = params[:path].to_s.start_with?('/') ? params[:path] : '/' %>
<%= render 'shared/turbo_modal', title: local_assigns[:title] do %> <%= render 'shared/turbo_modal', title: local_assigns[:title] do %>
<%= form_for '', url: params[:path], method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <%= form_for '', url: filter_path, method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<%= hidden_field_tag :q, params[:q] if params[:q].present? %> <%= hidden_field_tag :q, params[:q] if params[:q].present? %>
<% local_assigns[:default_params].each do |key, value| %> <% local_assigns[:default_params].each do |key, value| %>
<%= hidden_field_tag(key, value) if value.present? %> <%= hidden_field_tag(key, value) if value.present? %>
@ -10,7 +11,7 @@
</div> </div>
<% if params[:with_remove] %> <% if params[:with_remove] %>
<div class="text-center w-full mt-4"> <div class="text-center w-full mt-4">
<%= link_to t('remove_filter'), "#{params[:path]}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %> <%= link_to t('remove_filter'), "#{filter_path}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>

Loading…
Cancel
Save