[privacy] remove Community Chatwoot Hub egress
Remove Community Chatwoot Hub URL/push relay/sync/registration/event/changelog egress. changelog.js becomes a local empty-feed adapter (no axios/fetch/network) exporting the default ChangelogApi; links.js drops the Hub changelog URL. lib/chatwoot_hub.rb removes base_url/push_notification_url/billing_base_url/ instance_config/send_push/send_push_with_response; billing_url reads only an explicit CHATWOOT_BILLING_URL env, HTTPS-only with host and no userinfo, and never falls back to a Hub URL. Enterprise proprietary base_url override is preserved (spec uses singleton_class.instance_methods(false) for edition-safety). privacy_audit uses a narrow per-file/per-rule Enterprise exception (hub-url only) and privacy_audit_test.sh proves forbidden Enterprise runtime lines are still detected; deployment privacy guard unchanged. Approved by independent five-key review deleg_49d6ee2e (passed=true, blocking arrays empty).
This commit is contained in:
@@ -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: [] } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
app/javascript/dashboard/api/specs/changelog.spec.js
Normal file
24
app/javascript/dashboard/api/specs/changelog.spec.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Upgrade now</span>
|
||||
</a>
|
||||
<% if (billing_url = ChatwootHub.billing_url).present? %>
|
||||
<a href="<%= billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Upgrade now</span>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
@@ -47,12 +47,14 @@
|
||||
</div>
|
||||
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
</div>
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="px-1">Manage</span>
|
||||
</button>
|
||||
</a>
|
||||
<% if (billing_url = ChatwootHub.billing_url).present? %>
|
||||
<a href="<%= billing_url %>" target="_blank" rel="noopener noreferrer">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="px-1">Manage</span>
|
||||
</button>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user