From 67f39aad8afef3456e050ecbe8a7e6fbc3aed547 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 29 Dec 2025 14:51:11 -0600 Subject: [PATCH 1/9] Don't update counter if value hasn't changed --- app/javascript/controllers/css_variable_counter_controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js index 1fa2cd5b9..41aaec0e0 100644 --- a/app/javascript/controllers/css_variable_counter_controller.js +++ b/app/javascript/controllers/css_variable_counter_controller.js @@ -13,7 +13,9 @@ export default class extends Controller { } connect() { - this.#updateCounter() + if (this.itemTargets.length > 0) { + this.#updateCounter() + } } itemTargetConnected() { From b9c11ad76826c0cde0878a82dd89dc23cb2a819d Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 29 Dec 2025 15:30:55 -0600 Subject: [PATCH 2/9] Only enable transitions on user interaction --- app/javascript/controllers/collapsible_columns_controller.js | 5 +++-- app/views/boards/show/_columns.html.erb | 2 +- app/views/public/boards/show/_columns.html.erb | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 2c97fcd09..fb52385a7 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -25,6 +25,7 @@ export default class extends Controller { } toggle({ target }) { + this.#enableTransitions() const column = target.closest('[data-collapsible-columns-target="column"]') this.#toggleColumn(column); } @@ -35,13 +36,14 @@ export default class extends Controller { } } - async restoreState(event) { + async restoreState() { await nextFrame() await this.#restoreColumnsDisablingTransitions() } focusOnColumn({ target }) { if (this.#isCollapsed(target)) { + this.#enableTransitions() this.#collapseAllExcept(target) this.#expand(target) } @@ -52,7 +54,6 @@ export default class extends Controller { this.#restoreColumns() await nextFrame() - this.#enableTransitions() } #disableTransitions() { diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index bf7a76fcc..b3f77a790 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,4 +1,4 @@ -<%= tag.div class: "card-columns hide-scrollbar", data: { +<%= tag.div class: "card-columns no-transitions hide-scrollbar", data: { controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", diff --git a/app/views/public/boards/show/_columns.html.erb b/app/views/public/boards/show/_columns.html.erb index 59f2b5e39..471816bfe 100644 --- a/app/views/public/boards/show/_columns.html.erb +++ b/app/views/public/boards/show/_columns.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag :cards_container do %> -
Date: Mon, 29 Dec 2025 21:45:06 -0800 Subject: [PATCH 3/9] Block IPv4-compatible IPv6 addresses in SSRF protection (#2273) The SSRF filter checked ipv4_mapped? but not ipv4_compat?, allowing addresses like ::169.254.169.254 to bypass the link-local check and reach cloud metadata endpoints. Changes: - Add ipv4_compat? check to block deprecated IPv4-compatible format - Rename private_address? to blocked_address? (more accurate - method blocks more than just RFC 1918 private ranges) - Add IPv6 test coverage for both mapped and compat formats Both ipv4_mapped and ipv4_compat formats are blocked entirely as defense-in-depth: DNS never returns these formats, so they only appear in attack scenarios. HackerOne: #3481701 --- app/models/ssrf_protection.rb | 12 ++++++++--- test/models/ssrf_protection_test.rb | 32 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index a73b6b3be..40be7d157 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -16,13 +16,19 @@ module SsrfProtection def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) - public_ips = ip_addresses.reject { |ip| private_address?(ip) } + public_ips = ip_addresses.reject { |ip| blocked_address?(ip) } public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end - def private_address?(ip) + def blocked_address?(ip) ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) - ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip) + + ip.private? || + ip.loopback? || + ip.link_local? || + ip.ipv4_mapped? || + ip.ipv4_compat? || + in_disallowed_range?(ip) end private diff --git a/test/models/ssrf_protection_test.rb b/test/models/ssrf_protection_test.rb index d56f7390a..550909f89 100644 --- a/test/models/ssrf_protection_test.rb +++ b/test/models/ssrf_protection_test.rb @@ -51,6 +51,38 @@ class SsrfProtectionTest < ActiveSupport::TestCase assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com") end + # IPv6 address format tests (SSRF bypass prevention) + + test "blocks IPv4-mapped IPv6 addresses with private IPs" do + stub_dns_resolution("::ffff:192.168.1.1") + assert_nil SsrfProtection.resolve_public_ip("mapped-private.example.com") + end + + test "blocks IPv4-mapped IPv6 addresses with link-local IPs" do + stub_dns_resolution("::ffff:169.254.169.254") + assert_nil SsrfProtection.resolve_public_ip("mapped-metadata.example.com") + end + + test "blocks IPv4-mapped IPv6 addresses even with public IPs" do + stub_dns_resolution("::ffff:93.184.216.34") + assert_nil SsrfProtection.resolve_public_ip("mapped-public.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses with private IPs" do + stub_dns_resolution("::192.168.1.1") + assert_nil SsrfProtection.resolve_public_ip("compat-private.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses with link-local IPs" do + stub_dns_resolution("::169.254.169.254") + assert_nil SsrfProtection.resolve_public_ip("compat-metadata.example.com") + end + + test "blocks IPv4-compatible IPv6 addresses even with public IPs" do + stub_dns_resolution("::93.184.216.34") + assert_nil SsrfProtection.resolve_public_ip("compat-public.example.com") + end + private def stub_dns_resolution(*ips) dns_mock = mock("dns") From 711eec133a46cbde8069342e6b5ba306e0403001 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 30 Dec 2025 12:30:55 -0600 Subject: [PATCH 4/9] Max card count equals geared pagination size --- app/assets/stylesheets/card-columns.css | 10 ++++++---- .../controllers/collapsible_columns_controller.js | 5 ++--- .../controllers/css_variable_counter_controller.js | 2 +- app/views/boards/show/_columns.html.erb | 2 +- app/views/boards/show/_expander.html.erb | 2 +- app/views/public/boards/show/_columns.html.erb | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index aed15d90f..832e8c169 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -15,7 +15,7 @@ --column-width-collapsed: 40px; --column-width-expanded: 450px; --progress-increment: var(--progress-max-height) / var(--progress-max-cards); - --progress-max-cards: 20; + --progress-max-cards: 15; /* should match first geared pagination page size */ --progress-max-height: 50dvh; container-type: inline-size; @@ -35,6 +35,7 @@ } &:has(.cards) { + block-size: 100%; min-block-size: 20lh; } @@ -148,7 +149,7 @@ } .no-transitions & { - transition: none; + /* transition: none; */ } } @@ -321,7 +322,7 @@ } .no-transitions &:after { - transition: none; + /* transition: none; */ } .cards.is-collapsed & { @@ -344,6 +345,7 @@ &:after { block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)); + max-block-size: none; opacity: 1; inline-size: var(--column-width-collapsed); } @@ -756,7 +758,7 @@ --column-padding-expanded: calc(var(--column-gap) * 2); --column-width-collapsed: 40px; --progress-increment: var(--progress-max-width) / var(--progress-max-cards); - --progress-max-cards: 20; + --progress-max-cards: 15; /* should match first geared pagination page size */ --progress-max-width: 100%; padding-inline: 3vw; diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index fb52385a7..2c97fcd09 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -25,7 +25,6 @@ export default class extends Controller { } toggle({ target }) { - this.#enableTransitions() const column = target.closest('[data-collapsible-columns-target="column"]') this.#toggleColumn(column); } @@ -36,14 +35,13 @@ export default class extends Controller { } } - async restoreState() { + async restoreState(event) { await nextFrame() await this.#restoreColumnsDisablingTransitions() } focusOnColumn({ target }) { if (this.#isCollapsed(target)) { - this.#enableTransitions() this.#collapseAllExcept(target) this.#expand(target) } @@ -54,6 +52,7 @@ export default class extends Controller { this.#restoreColumns() await nextFrame() + this.#enableTransitions() } #disableTransitions() { diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js index 41aaec0e0..5b4e9c940 100644 --- a/app/javascript/controllers/css_variable_counter_controller.js +++ b/app/javascript/controllers/css_variable_counter_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = [ "item", "counter" ] static values = { propertyName: String, - maxValue: { type: Number, default: 20 } + maxValue: { type: Number, default: 15 } // should match first geared pagination page size } initialize() { diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index b3f77a790..bf7a76fcc 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,4 +1,4 @@ -<%= tag.div class: "card-columns no-transitions hide-scrollbar", data: { +<%= tag.div class: "card-columns hide-scrollbar", data: { controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", diff --git a/app/views/boards/show/_expander.html.erb b/app/views/boards/show/_expander.html.erb index d58b68715..c66d12ea3 100644 --- a/app/views/boards/show/_expander.html.erb +++ b/app/views/boards/show/_expander.html.erb @@ -1,5 +1,5 @@