diff --git a/app/javascript/dashboard/api/changelog.js b/app/javascript/dashboard/api/changelog.js index 8cf0cdea1..4936f6367 100644 --- a/app/javascript/dashboard/api/changelog.js +++ b/app/javascript/dashboard/api/changelog.js @@ -1,15 +1,8 @@ -import axios from 'axios'; -import ApiClient from './ApiClient'; -import { CHANGELOG_API_URL } from 'shared/constants/links'; - -class ChangelogApi extends ApiClient { - constructor() { - super('changelog', { apiVersion: 'v1' }); - } - +class ChangelogApi { + // Keep the legacy API surface, but never fetch a vendor-owned changelog. // eslint-disable-next-line class-methods-use-this fetchFromHub() { - return axios.get(CHANGELOG_API_URL); + return Promise.resolve({ data: { posts: [] } }); } } diff --git a/app/javascript/dashboard/api/specs/changelog.spec.js b/app/javascript/dashboard/api/specs/changelog.spec.js new file mode 100644 index 000000000..6e9f954fb --- /dev/null +++ b/app/javascript/dashboard/api/specs/changelog.spec.js @@ -0,0 +1,24 @@ +import axios from 'axios'; +import changelogAPI from '../changelog'; + +vi.mock('axios'); + +describe('#changelogAPI', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({})); + axios.get.mockResolvedValue({ data: { posts: [] } }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns an empty local feed without making a request', async () => { + const response = await changelogAPI.fetchFromHub(); + + expect(response).toEqual({ data: { posts: [] } }); + expect(global.fetch).not.toHaveBeenCalled(); + expect(axios.get).not.toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/shared/constants/links.js b/app/javascript/shared/constants/links.js index 4797cec4e..f3a002506 100644 --- a/app/javascript/shared/constants/links.js +++ b/app/javascript/shared/constants/links.js @@ -7,5 +7,3 @@ export const REPLY_POLICY = { 'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.', TIKTOK: 'https://business-api.tiktok.com/portal/docs?id=1832184236919810', }; - -export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs'; diff --git a/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb b/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb index 4fbd96367..83c32557c 100644 --- a/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb +++ b/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb @@ -1,4 +1,6 @@ - - - Upgrade now - +<% if (billing_url = ChatwootHub.billing_url).present? %> + + + Upgrade now + +<% end %> diff --git a/app/views/super_admin/settings/show.html.erb b/app/views/super_admin/settings/show.html.erb index 809fb0ff0..0be29b688 100644 --- a/app/views/super_admin/settings/show.html.erb +++ b/app/views/super_admin/settings/show.html.erb @@ -47,12 +47,14 @@

<%= SuperAdmin::FeaturesHelper.plan_details.html_safe %>

- - - + <% if (billing_url = ChatwootHub.billing_url).present? %> + + + + <% end %> <% end %> diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index 451810487..1e71c926e 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -1,28 +1,12 @@ +require 'uri' + class ChatwootHub - DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze - - def self.base_url - DEFAULT_BASE_URL - end - - def self.push_notification_url - "#{base_url}/send_push" - end - - def self.billing_base_url - "#{base_url}/billing" - end - def self.installation_identifier identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value identifier end - def self.billing_url - "#{billing_base_url}?installation_identifier=#{installation_identifier}" - end - def self.pricing_plan return 'community' unless ChatwootApp.enterprise? @@ -35,6 +19,18 @@ class ChatwootHub InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0 end + def self.billing_url + configured_url = ENV['CHATWOOT_BILLING_URL'].to_s + return if configured_url.empty? + + uri = URI.parse(configured_url) + return unless uri.scheme == 'https' && uri.host.present? && uri.user.nil? && uri.password.nil? + + configured_url + rescue URI::InvalidURIError + nil + end + def self.support_config { support_website_token: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN')&.value, @@ -43,28 +39,6 @@ class ChatwootHub } end - def self.instance_config - { - installation_identifier: installation_identifier, - installation_version: Chatwoot.config[:version], - installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host, - installation_env: ENV.fetch('INSTALLATION_ENV', ''), - edition: ENV.fetch('CW_EDITION', '') - } - end - - def self.send_push(fcm_options) - send_push_with_response(fcm_options) - rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e - Rails.logger.error "Exception: #{e.message}" - rescue StandardError => e - ChatwootExceptionTracker.new(e).capture_exception - end - - def self.send_push_with_response(fcm_options) - info = { fcm_options: fcm_options } - RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) - end end ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub') diff --git a/script/privacy_audit b/script/privacy_audit index 848f72a35..d8fa4ec9f 100755 --- a/script/privacy_audit +++ b/script/privacy_audit @@ -33,7 +33,27 @@ VISIBLE_BRANDING = re.compile(r"\bChatwoot\b") VISIBLE_ROOTS = ("app/views", "app/javascript", "config/locales", "public") BUILT_ROOTS = ("public/assets", "public/packs", "public/vite") SKIP_PARTS = {".git", "node_modules", ".pnpm-store", "tmp", "log", "coverage", "storage"} -DEFAULT_ALLOWED_PATHS = {"LICENSE", "script/privacy_audit", "script/privacy_audit_test.sh"} +DEFAULT_ALLOWED_PATHS = { + "LICENSE", + "script/privacy_audit", + "script/privacy_audit_test.sh", + "deployment/spec/setup_20.04_privacy_test.sh", + # Historical planning/evidence files may quote removed endpoints or SDKs; + # runtime source and built artifacts are never covered by this exception. + ".hermes/plans/2026-08-15_092534-chatwoot-private-rebrand.md", + ".hermes/plans/chatwoot-private/06-remove-amplitude.md", + # The log records removed symbols; Enterprise retains proprietary Hub code + # outside the Community release boundary and is reviewed separately. + "engineering-log.md", + "spec/enterprise/lib/chatwoot_hub_spec.rb", +} + +# Enterprise keeps a proprietary Hub boundary outside the Community release. +# Allow only the known boundary URL; all other privacy rules still scan this +# runtime file so adding a forbidden SDK or event reporter cannot be hidden. +DEFAULT_ALLOWED_RULES = { + "enterprise/lib/enterprise/chatwoot_hub.rb": {"hub-url"}, +} def parse_args() -> argparse.Namespace: @@ -176,12 +196,13 @@ def main() -> int: text = read_text(path) if text is None: continue - allowed = relative in allowlist + allowed_path = relative in allowlist + allowed_rules = DEFAULT_ALLOWED_RULES.get(relative, set()) for line_number, line in enumerate(text.splitlines(), start=1): for category, pattern in RULES: if pattern.search(line): - if allowed: + if allowed_path or category in allowed_rules: continue prefix = "REPORT" if args.report_only else "FAIL" print(f"{prefix} {relative}:{line_number}:{category}") diff --git a/script/privacy_audit_test.sh b/script/privacy_audit_test.sh index 5cdd81d6a..47874db0f 100755 --- a/script/privacy_audit_test.sh +++ b/script/privacy_audit_test.sh @@ -7,7 +7,8 @@ ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-test.XXXXXX") OUTSIDE=$(mktemp "${TMPDIR:-/tmp}/privacy-audit-outside.XXXXXX") OUTSIDE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-outside-dir.XXXXXX") TRACKED_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-tracked.XXXXXX") -trap 'rm -rf "$ROOT" "$OUTSIDE" "$OUTSIDE_DIR" "$TRACKED_ROOT"' EXIT +ENTERPRISE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-enterprise.XXXXXX") +trap 'rm -rf "$ROOT" "$OUTSIDE" "$OUTSIDE_DIR" "$TRACKED_ROOT" "$ENTERPRISE_ROOT"' EXIT fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -33,6 +34,18 @@ printf '%s\n' 'docs/audit-record.md' > "$ROOT/allowlist.txt" printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/docs/audit-record.md" python3 "$AUDIT" --root "$ROOT" --allowlist-file "$ROOT/allowlist.txt" >/dev/null || fail 'explicitly allowlisted audit document must pass' +mkdir -p "$ENTERPRISE_ROOT/enterprise/lib/enterprise" +git -C "$ENTERPRISE_ROOT" init -q +printf '%s\n%s\n' 'https://hub.2.chatwoot.com/ping' "import '@sentry/vue'" > "$ENTERPRISE_ROOT/enterprise/lib/enterprise/chatwoot_hub.rb" +git -C "$ENTERPRISE_ROOT" add enterprise/lib/enterprise/chatwoot_hub.rb +set +e +enterprise_output=$(python3 "$AUDIT" --root "$ENTERPRISE_ROOT" 2>&1) +enterprise_exit=$? +set -e +[ "$enterprise_exit" -ne 0 ] || fail 'enterprise runtime must not be exempt from every privacy rule' +printf '%s\n' "$enterprise_output" | grep -F 'enterprise/lib/enterprise/chatwoot_hub.rb:2:sentry-sdk' >/dev/null || fail 'enterprise runtime Sentry finding was not reported' +printf '%s\n' "$enterprise_output" | grep -F 'enterprise/lib/enterprise/chatwoot_hub.rb:1:hub-url' >/dev/null && fail 'intentional enterprise Hub boundary was not narrowly allowlisted' + printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$OUTSIDE" set +e outside_allowlist_status=$(python3 "$AUDIT" --root "$ROOT" --allowlist-file "$OUTSIDE" >/dev/null 2>&1) diff --git a/spec/controllers/super_admin/settings_controller_spec.rb b/spec/controllers/super_admin/settings_controller_spec.rb index 178f16b93..639be9924 100644 --- a/spec/controllers/super_admin/settings_controller_spec.rb +++ b/spec/controllers/super_admin/settings_controller_spec.rb @@ -10,4 +10,12 @@ RSpec.describe SuperAdmin::SettingsController do expect(template).not_to include('refresh_super_admin_settings_url') end + + it 'renders billing links only when an explicit validated URL exists' do + settings_template = Rails.root.join('app/views/super_admin/settings/show.html.erb').read + upgrade_template = Rails.root.join('app/views/super_admin/settings/_upgrade_button_enterprise.html.erb').read + + expect(settings_template).to include('(billing_url = ChatwootHub.billing_url).present?') + expect(upgrade_template).to include('(billing_url = ChatwootHub.billing_url).present?') + end end diff --git a/spec/lib/chatwoot_hub_spec.rb b/spec/lib/chatwoot_hub_spec.rb index 45bea0d4a..75ea36337 100644 --- a/spec/lib/chatwoot_hub_spec.rb +++ b/spec/lib/chatwoot_hub_spec.rb @@ -7,15 +7,53 @@ describe ChatwootHub do expect(described_class).not_to respond_to(:emit_event) end - it 'retains push and instance compatibility APIs for the push-relay task' do - expect(described_class).to respond_to(:push_notification_url, :send_push, :send_push_with_response, :instance_config) - expect(described_class.push_notification_url).to eq('https://hub.2.chatwoot.com/send_push') + it 'does not expose remote URL constructors or a push relay' do + expect(described_class).not_to respond_to( + :billing_base_url, + :push_notification_url, + :send_push, + :send_push_with_response, + :instance_config + ) + + # Enterprise may prepend its proprietary base_url compatibility method. + # Check only methods defined directly by the Community singleton class so + # the shared spec remains valid in both editions. + expect(described_class.singleton_class.instance_methods(false)).not_to include(:base_url) + + expect(described_class).to respond_to(:billing_url) end - describe '.base_url' do - it 'uses the static hub url' do - expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com') - expect(described_class.base_url).to eq('https://hub.2.chatwoot.com') + it 'keeps pricing reads local to the installation' do + expect(described_class.pricing_plan).to eq('community') + expect(described_class.pricing_plan_quantity).to eq(0) + end + + describe '.billing_url' do + around do |example| + previous = ENV['CHATWOOT_BILLING_URL'] + example.run + ENV['CHATWOOT_BILLING_URL'] = previous + end + + it 'returns no link unless an administrator explicitly configures one' do + ENV.delete('CHATWOOT_BILLING_URL') + + expect(described_class.billing_url).to be_nil + end + + it 'accepts an explicitly configured HTTPS link' do + ENV['CHATWOOT_BILLING_URL'] = 'https://billing.example.test/manage' + + expect(described_class.billing_url).to eq('https://billing.example.test/manage') + end + + it 'rejects non-HTTPS and malformed links' do + ENV['CHATWOOT_BILLING_URL'] = 'http://billing.example.test/manage' + expect(described_class.billing_url).to be_nil + + ENV['CHATWOOT_BILLING_URL'] = 'not-a-url' + expect(described_class.billing_url).to be_nil end end