fix: security hardening — move meta-ads to header auth, encode URLs

Critical:
- meta-ads: move access_token from URL query string to Authorization
  header to prevent credential leakage in server logs and referrers

Medium (URL encoding):
- g2: encode state and date filter values
- trustpilot: use URLSearchParams for reviews list params
- typeform: encode response IDs in delete endpoint
- demio: encode event type filter
- lemlist: encode email addresses in URL path segments

Docs:
- Fix 6 missing env vars in CLI README auth table
- Fix .gitignore typo (extra space in .DS_Store pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Corey Haines 2026-02-17 22:39:16 -08:00
parent 47b4571ca2
commit c1be574c8b
8 changed files with 31 additions and 29 deletions

View file

@ -37,7 +37,7 @@ Every CLI reads credentials from environment variables:
| CLI | Environment Variable |
|-----|---------------------|
| `activecampaign` | `ACTIVECAMPAIGN_API_KEY`, `ACTIVECAMPAIGN_API_URL` |
| `adobe-analytics` | `ADOBE_CLIENT_ID`, `ADOBE_ACCESS_TOKEN` |
| `adobe-analytics` | `ADOBE_ACCESS_TOKEN`, `ADOBE_CLIENT_ID`, `ADOBE_COMPANY_ID` |
| `ahrefs` | `AHREFS_API_KEY` |
| `amplitude` | `AMPLITUDE_API_KEY`, `AMPLITUDE_SECRET_KEY` |
| `apollo` | `APOLLO_API_KEY` |
@ -52,7 +52,7 @@ Every CLI reads credentials from environment variables:
| `dub` | `DUB_API_KEY` |
| `g2` | `G2_API_TOKEN` |
| `ga4` | `GA4_ACCESS_TOKEN` |
| `google-ads` | `GOOGLE_ADS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN` |
| `google-ads` | `GOOGLE_ADS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_CUSTOMER_ID` |
| `google-search-console` | `GSC_ACCESS_TOKEN` |
| `hotjar` | `HOTJAR_CLIENT_ID`, `HOTJAR_CLIENT_SECRET` |
| `intercom` | `INTERCOM_API_KEY` |
@ -63,13 +63,13 @@ Every CLI reads credentials from environment variables:
| `livestorm` | `LIVESTORM_API_TOKEN` |
| `mailchimp` | `MAILCHIMP_API_KEY` |
| `mention-me` | `MENTIONME_API_KEY` |
| `meta-ads` | `META_ACCESS_TOKEN` |
| `meta-ads` | `META_ACCESS_TOKEN`, `META_AD_ACCOUNT_ID` |
| `mixpanel` | `MIXPANEL_TOKEN` (ingestion), `MIXPANEL_API_KEY` + `MIXPANEL_SECRET` (query) |
| `onesignal` | `ONESIGNAL_REST_API_KEY`, `ONESIGNAL_APP_ID` |
| `optimizely` | `OPTIMIZELY_API_KEY` |
| `paddle` | `PADDLE_API_KEY` |
| `paddle` | `PADDLE_API_KEY`, `PADDLE_SANDBOX` (optional) |
| `partnerstack` | `PARTNERSTACK_PUBLIC_KEY`, `PARTNERSTACK_SECRET_KEY` |
| `plausible` | `PLAUSIBLE_API_KEY` |
| `plausible` | `PLAUSIBLE_API_KEY`, `PLAUSIBLE_BASE_URL` (optional, for self-hosted) |
| `postmark` | `POSTMARK_API_KEY` |
| `resend` | `RESEND_API_KEY` |
| `rewardful` | `REWARDFUL_API_KEY` |
@ -77,7 +77,7 @@ Every CLI reads credentials from environment variables:
| `segment` | `SEGMENT_WRITE_KEY` (tracking), `SEGMENT_ACCESS_TOKEN` (profile) |
| `semrush` | `SEMRUSH_API_KEY` |
| `sendgrid` | `SENDGRID_API_KEY` |
| `tiktok-ads` | `TIKTOK_ACCESS_TOKEN` |
| `tiktok-ads` | `TIKTOK_ACCESS_TOKEN`, `TIKTOK_ADVERTISER_ID` |
| `tolt` | `TOLT_API_KEY` |
| `trustpilot` | `TRUSTPILOT_API_KEY`, `TRUSTPILOT_API_SECRET`, `TRUSTPILOT_BUSINESS_UNIT_ID` |
| `typeform` | `TYPEFORM_API_KEY` |

View file

@ -72,7 +72,7 @@ async function main() {
case 'list': {
const type = args.type
let qs = ''
if (type) qs = `?type=${type}`
if (type) qs = `?type=${encodeURIComponent(type)}`
result = await api('GET', `/events${qs}`)
break
}

View file

@ -64,7 +64,7 @@ async function main() {
case 'list': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (productId) qs += `&filter[product_id]=${productId}`
if (args.state) qs += `&filter[state]=${args.state}`
if (args.state) qs += `&filter[state]=${encodeURIComponent(args.state)}`
result = await api('GET', `/survey-responses${qs}`)
break
}
@ -152,8 +152,8 @@ async function main() {
switch (sub) {
case 'visitors': {
let qs = `?page[size]=${perPage}&page[number]=${page}`
if (args.start) qs += `&filter[start_date]=${args.start}`
if (args.end) qs += `&filter[end_date]=${args.end}`
if (args.start) qs += `&filter[start_date]=${encodeURIComponent(args.start)}`
if (args.end) qs += `&filter[end_date]=${encodeURIComponent(args.end)}`
result = await api('GET', `/tracking-events${qs}`)
break
}

View file

@ -112,7 +112,7 @@ async function main() {
case 'get': {
if (!args['campaign-id']) { result = { error: '--campaign-id required' }; break }
if (!args.email) { result = { error: '--email required' }; break }
result = await api('GET', `/campaigns/${args['campaign-id']}/leads/${args.email}`)
result = await api('GET', `/campaigns/${args['campaign-id']}/leads/${encodeURIComponent(args.email)}`)
break
}
case 'add': {
@ -122,13 +122,13 @@ async function main() {
if (args['first-name']) body.firstName = args['first-name']
if (args['last-name']) body.lastName = args['last-name']
if (args.company) body.companyName = args.company
result = await api('POST', `/campaigns/${args['campaign-id']}/leads/${args.email}`, body)
result = await api('POST', `/campaigns/${args['campaign-id']}/leads/${encodeURIComponent(args.email)}`, body)
break
}
case 'delete': {
if (!args['campaign-id']) { result = { error: '--campaign-id required' }; break }
if (!args.email) { result = { error: '--email required' }; break }
result = await api('DELETE', `/campaigns/${args['campaign-id']}/leads/${args.email}`)
result = await api('DELETE', `/campaigns/${args['campaign-id']}/leads/${encodeURIComponent(args.email)}`)
break
}
default:
@ -147,12 +147,12 @@ async function main() {
}
case 'add': {
if (!args.email) { result = { error: '--email required' }; break }
result = await api('POST', `/unsubscribes/${args.email}`)
result = await api('POST', `/unsubscribes/${encodeURIComponent(args.email)}`)
break
}
case 'delete': {
if (!args.email) { result = { error: '--email required' }; break }
result = await api('DELETE', `/unsubscribes/${args.email}`)
result = await api('DELETE', `/unsubscribes/${encodeURIComponent(args.email)}`)
break
}
default:

View file

@ -10,16 +10,17 @@ if (!TOKEN) {
}
async function api(method, path, body) {
const separator = path.includes('?') ? '&' : '?'
const url = `${BASE_URL}${path}${separator}access_token=${TOKEN}`
const opts = { method, headers: {} }
const url = `${BASE_URL}${path}`
const opts = {
method,
headers: { 'Authorization': `Bearer ${TOKEN}` },
}
if (body) {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
if (args['dry-run']) {
const dryRunUrl = url.replace(TOKEN, '***')
return { _dry_run: true, method, url: dryRunUrl, headers: opts.headers, body: body || undefined }
return { _dry_run: true, method, url, headers: { ...opts.headers, Authorization: '***' }, body: body || undefined }
}
const res = await fetch(url, opts)
const text = await res.text()

View file

@ -122,7 +122,7 @@ async function main() {
case 'web-links': {
if (!businessUnitId) { result = { error: '--business-unit or TRUSTPILOT_BUSINESS_UNIT_ID required' }; break }
const locale = args.locale || 'en-US'
result = await api('GET', `/business-units/${businessUnitId}/web-links?locale=${locale}`)
result = await api('GET', `/business-units/${businessUnitId}/web-links?locale=${encodeURIComponent(locale)}`)
break
}
default:
@ -134,10 +134,10 @@ async function main() {
switch (sub) {
case 'list': {
if (!businessUnitId) { result = { error: '--business-unit or TRUSTPILOT_BUSINESS_UNIT_ID required' }; break }
const stars = args.stars ? `&stars=${args.stars}` : ''
const lang = args.language ? `&language=${args.language}` : ''
const orderBy = args['order-by'] || 'createdat.desc'
result = await api('GET', `/business-units/${businessUnitId}/reviews?perPage=${limit}&orderBy=${orderBy}${stars}${lang}`)
const reviewParams = new URLSearchParams({ perPage: String(limit), orderBy: args['order-by'] || 'createdat.desc' })
if (args.stars) reviewParams.set('stars', args.stars)
if (args.language) reviewParams.set('language', args.language)
result = await api('GET', `/business-units/${businessUnitId}/reviews?${reviewParams}`)
break
}
case 'get': {
@ -148,8 +148,9 @@ async function main() {
}
case 'private': {
if (!businessUnitId) { result = { error: '--business-unit or TRUSTPILOT_BUSINESS_UNIT_ID required' }; break }
const stars = args.stars ? `&stars=${args.stars}` : ''
result = await api('GET', `/private/business-units/${businessUnitId}/reviews?perPage=${limit}${stars}`, null, 'bearer')
const privateParams = new URLSearchParams({ perPage: String(limit) })
if (args.stars) privateParams.set('stars', args.stars)
result = await api('GET', `/private/business-units/${businessUnitId}/reviews?${privateParams}`, null, 'bearer')
break
}
case 'latest':

View file

@ -128,7 +128,7 @@ async function main() {
if (!id) { result = { error: '--id required (form ID)' }; break }
const responseIds = args['response-ids']
if (!responseIds) { result = { error: '--response-ids required (comma-separated)' }; break }
result = await api('DELETE', `/forms/${id}/responses?included_response_ids=${responseIds}`)
result = await api('DELETE', `/forms/${id}/responses?included_response_ids=${encodeURIComponent(responseIds)}`)
break
}
default: