Skip to content

Commit c70a328

Browse files
reakaleekclaude
andauthored
Load the AWS WAF SDK from the page head (#4841)
* Wait for AwsWafIntegration after the WAF SDK script loads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Explain why the WAF integration URL is the CAPTCHA jsapi.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Load the AWS WAF SDK from the page head and wait for its global Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: skip the WAF SDK tag on air-gapped builds via FrontendConfig (per review by @infra-vault-gh-plugin-prod) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: load the WAF SDK async so it can't delay DOMContentLoaded (per review by @infra-vault-gh-plugin-prod) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Jan Calanog <reakaleek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2b16fb6 commit c70a328

8 files changed

Lines changed: 164 additions & 105 deletions

File tree

‎docs/development/page-feedback.md‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,30 @@ name; it does not replace index versioning.
7373

7474
The web ACL can require a valid `aws-waf-token` cookie on `/docs/_api/v1/page-feedback`.
7575
A request without one gets an HTTP 202 challenge that a plain `fetch()` can't solve.
76-
When `waf_integration_url` is set on an environment in `config/assembler.yml`, the
77-
`<page-feedback>` component loads the AWS WAF JavaScript SDK from that URL and sends
78-
the `PUT` through `AwsWafIntegration.fetch`. Take the URL from the AWS WAF console's
79-
Application integration page. Use the CloudFront (`edge`) script URL, not the regional
80-
one. It belongs to the AWS account and is not tied to a web ACL, so environments in the
81-
same account share it.
82-
83-
With no URL (local dev, isolated, codex, air-gapped) nothing loads and the component
84-
uses plain `fetch`. If the SDK is configured but fails to load or times out, the
85-
submit shows the normal error state instead of sending an unchallenged request.
86-
87-
Browser telemetry uses the same URL. When it's set, the OTLP exporters wait for the
88-
token before each export to `/docs/_api/v1/o/{t,l,m}`, so the web ACL can challenge
89-
those paths too. If the token can't be fetched within 5 seconds, exports go out
90-
without it. The SDK still starts at page load, so instrumentation isn't delayed.
76+
When `waf_integration_url` is set on an environment in `config/assembler.yml`, the page
77+
`<head>` loads the AWS WAF JavaScript SDK from that URL (`Layout/_Head.cshtml`). The SDK
78+
fetches the token in the background and stores it in the cookie. Take the URL from the
79+
AWS WAF console's Application integration page. Use the CloudFront (`edge`) script URL,
80+
not the regional one. It belongs to the AWS account and is not tied to a web ACL, so
81+
environments in the same account share it.
82+
83+
The URL is the `jsapi.js` script from the **CAPTCHA integration** tab. The
84+
**Intelligent threat integration** tab only lists web ACLs that use the ACFP or ATP rule
85+
groups or targeted Bot Control, and the docs web ACLs use common Bot Control. `jsapi.js`
86+
loads `challenge.js`, which defines `AwsWafIntegration` a little later, so
87+
`waitForWafSdk` in `Assets/waf.ts` waits up to 5 seconds for the global.
88+
89+
The `<page-feedback>` component sends the `PUT` through `AwsWafIntegration.fetch`. If the
90+
SDK doesn't appear in time, the submit shows the normal error state instead of sending
91+
an unchallenged request.
92+
93+
Browser telemetry waits for the token before each export to `/docs/_api/v1/o/{t,l,m}`,
94+
so the web ACL can challenge those paths too. If the token can't be fetched within 5
95+
seconds, exports go out without it. The telemetry SDK still starts at page load, so
96+
instrumentation isn't delayed.
97+
98+
With no URL (local dev, isolated, codex, air-gapped) no script loads, page feedback uses
99+
plain `fetch`, and telemetry doesn't wait.
91100

92101
External callers of other `/docs/_api/v1/*` paths can't run the SDK and stay on IP
93102
rate limits.

‎src/Elastic.Documentation.Site/Assets/telemetry/instrumentation.test.ts‎

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,10 @@ describe('initializeOtel', () => {
117117
mockUserAgent('Mozilla/5.0 Chrome/120.0.0.0')
118118
})
119119

120-
it('sends plain headers and loads no script without an SDK URL', () => {
120+
it('sends plain headers without an SDK URL', () => {
121121
initializeOtel()
122122

123123
expect(exportHeaders()).toEqual({ 'X-Docs-Session': 'active' })
124-
expect(document.head.querySelector('script')).toBeNull()
125124
})
126125

127126
it('holds each export until the WAF token is fetched', async () => {
@@ -166,31 +165,27 @@ describe('initializeOtel', () => {
166165
}
167166
})
168167

169-
it('retries the WAF token on the next export after a failed load', async () => {
170-
config.wafIntegrationUrl = sdkUrl
171-
172-
initializeOtel()
173-
const headers = exportHeadersFn()
174-
document.head
175-
.querySelector(`script[src="${sdkUrl}"]`)!
176-
.dispatchEvent(new Event('error'))
177-
await headers()
178-
179-
expect(
180-
document.head.querySelector(`script[src="${sdkUrl}"]`)
181-
).not.toBeNull()
182-
})
183-
184-
it('still exports when the WAF SDK fails to load', async () => {
185-
config.wafIntegrationUrl = sdkUrl
186-
187-
initializeOtel()
188-
const headers = exportHeadersFn()
189-
document.head
190-
.querySelector(`script[src="${sdkUrl}"]`)!
191-
.dispatchEvent(new Event('error'))
168+
it('exports without the token when the SDK never appears, then retries', async () => {
169+
vi.useFakeTimers()
170+
try {
171+
config.wafIntegrationUrl = sdkUrl
192172

193-
expect(await headers()).toEqual({ 'X-Docs-Session': 'active' })
173+
initializeOtel()
174+
const headers = exportHeadersFn()
175+
const first = headers()
176+
await vi.advanceTimersByTimeAsync(5000)
177+
expect(await first).toEqual({ 'X-Docs-Session': 'active' })
178+
179+
const getToken = vi.fn().mockResolvedValue('token')
180+
window.AwsWafIntegration = { fetch: vi.fn(), getToken }
181+
const second = headers()
182+
await vi.advanceTimersByTimeAsync(25)
183+
184+
expect(getToken).toHaveBeenCalled()
185+
expect(await second).toEqual({ 'X-Docs-Session': 'active' })
186+
} finally {
187+
vi.useRealTimers()
188+
}
194189
})
195190
})
196191
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { config } from './config'
2+
import { waitForWafSdk } from './waf'
3+
4+
const integration = {
5+
fetch: vi.fn(),
6+
getToken: vi.fn().mockResolvedValue('token'),
7+
}
8+
9+
beforeEach(() => {
10+
vi.useFakeTimers()
11+
config.wafIntegrationUrl = 'https://waf.example/jsapi.js'
12+
})
13+
14+
afterEach(() => {
15+
vi.useRealTimers()
16+
delete config.wafIntegrationUrl
17+
delete window.AwsWafIntegration
18+
})
19+
20+
// On edge, jsapi.js adds challenge.js, which defines AwsWafIntegration about 400 to 600 ms
21+
// after the page starts loading.
22+
it('resolves once AwsWafIntegration appears', async () => {
23+
const ready = waitForWafSdk()
24+
await vi.advanceTimersByTimeAsync(500)
25+
window.AwsWafIntegration = integration
26+
await vi.advanceTimersByTimeAsync(25)
27+
28+
await expect(ready).resolves.toBe(integration)
29+
})
30+
31+
it('rejects when AwsWafIntegration never appears', async () => {
32+
const ready = waitForWafSdk()
33+
const rejected = expect(ready).rejects.toThrow('AWS WAF SDK not available')
34+
await vi.advanceTimersByTimeAsync(5000)
35+
36+
await rejected
37+
})
38+
39+
it('resolves to null without an SDK URL', async () => {
40+
delete config.wafIntegrationUrl
41+
42+
await expect(waitForWafSdk()).resolves.toBeNull()
43+
})

‎src/Elastic.Documentation.Site/Assets/waf.ts‎

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,42 +13,22 @@ declare global {
1313

1414
const WAF_SDK_TIMEOUT = 5000
1515

16-
let wafSdkLoad: Promise<AwsWafIntegration> | undefined
16+
// Layout/_Head.cshtml loads the AWS WAF SDK when wafIntegrationUrl is set. Its jsapi.js adds
17+
// challenge.js, which defines AwsWafIntegration a little after the page loads, so this waits
18+
// for the global. Resolves to null without a URL, so callers fall back to plain fetch.
19+
export const waitForWafSdk = (): Promise<AwsWafIntegration | null> => {
20+
if (!config.wafIntegrationUrl) return Promise.resolve(null)
1721

18-
// Loads the AWS WAF JS SDK from config.wafIntegrationUrl. Resolves to null when no URL is
19-
// configured, so callers fall back to plain fetch.
20-
export const loadWafSdk = (): Promise<AwsWafIntegration | null> => {
21-
const url = config.wafIntegrationUrl
22-
if (!url) return Promise.resolve(null)
23-
if (window.AwsWafIntegration)
24-
return Promise.resolve(window.AwsWafIntegration)
25-
if (wafSdkLoad) return wafSdkLoad
26-
27-
wafSdkLoad = new Promise<AwsWafIntegration>((resolve, reject) => {
28-
const script = document.createElement('script')
29-
const fail = (reason: string) => {
30-
clearTimeout(timer)
31-
script.remove()
32-
reject(new Error(reason))
33-
}
34-
const timer = setTimeout(
35-
() => fail('AWS WAF SDK load timed out'),
36-
WAF_SDK_TIMEOUT
37-
)
38-
script.src = url
39-
script.defer = true
40-
script.onload = () => {
41-
clearTimeout(timer)
42-
const integration = window.AwsWafIntegration
43-
if (integration) resolve(integration)
44-
else fail('AWS WAF SDK loaded without AwsWafIntegration')
22+
const deadline = Date.now() + WAF_SDK_TIMEOUT
23+
return new Promise((resolve, reject) => {
24+
const check = () => {
25+
if (window.AwsWafIntegration) resolve(window.AwsWafIntegration)
26+
else if (Date.now() >= deadline)
27+
reject(new Error('AWS WAF SDK not available'))
28+
else setTimeout(check, 25)
4529
}
46-
script.onerror = () => fail('AWS WAF SDK failed to load')
47-
document.head.appendChild(script)
48-
}).finally(() => {
49-
wafSdkLoad = undefined
30+
check()
5031
})
51-
return wafSdkLoad
5232
}
5333

5434
// Resolves to true once the SDK has stored the aws-waf-token cookie, or to false when the SDK
@@ -58,7 +38,7 @@ export const waitForWafToken = (): Promise<boolean> => {
5838
const timeout = new Promise<boolean>((resolve) => {
5939
timer = setTimeout(() => resolve(false), WAF_SDK_TIMEOUT)
6040
})
61-
const token = loadWafSdk()
41+
const token = waitForWafSdk()
6242
.then((waf) => waf?.getToken())
6343
.then(
6444
() => true,

‎src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx‎

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -450,14 +450,11 @@ describe('PageFeedback', () => {
450450
})
451451

452452
describe('AWS WAF token', () => {
453-
const sdkUrl = 'https://waf.example/challenge.js'
454-
const findSdkScript = () =>
455-
document.head.querySelector<HTMLScriptElement>(
456-
`script[src="${sdkUrl}"]`
457-
)
453+
const sdkUrl = 'https://waf.example/jsapi.js'
458454

459-
const submitPositiveFeedback = async () => {
460-
const user = userEvent.setup()
455+
const renderAndReact = async (
456+
user = userEvent.setup()
457+
): Promise<ReturnType<typeof userEvent.setup>> => {
461458
render(
462459
<PageFeedback pageUrl="/docs/test-page" pageTitle="Test page" />
463460
)
@@ -470,25 +467,20 @@ describe('PageFeedback', () => {
470467
}
471468

472469
afterEach(() => {
470+
vi.useRealTimers()
473471
delete config.wafIntegrationUrl
474472
delete window.AwsWafIntegration
475-
document.head.querySelectorAll('script').forEach((s) => s.remove())
476473
})
477474

478-
it('sends the PUT through AwsWafIntegration.fetch once the SDK loads', async () => {
475+
it('sends the PUT through AwsWafIntegration.fetch once the SDK is ready', async () => {
479476
config.wafIntegrationUrl = sdkUrl
480477
const wafFetch = vi.fn().mockResolvedValue(successfulResponse)
481478

482-
await submitPositiveFeedback()
483-
const script = findSdkScript()
484-
expect(script).not.toBeNull()
479+
await renderAndReact()
485480
window.AwsWafIntegration = {
486481
fetch: wafFetch,
487482
getToken: vi.fn().mockResolvedValue('token'),
488483
}
489-
act(() => {
490-
script!.dispatchEvent(new Event('load'))
491-
})
492484

493485
await waitFor(() =>
494486
expect(wafFetch).toHaveBeenCalledWith(
@@ -507,22 +499,22 @@ describe('PageFeedback', () => {
507499
expect(global.fetch).not.toHaveBeenCalled()
508500
})
509501

510-
it('uses plain fetch and loads no script when no SDK URL is configured', async () => {
511-
await submitPositiveFeedback()
502+
it('uses plain fetch when no SDK URL is configured', async () => {
503+
await renderAndReact()
512504

513505
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1))
514-
expect(document.head.querySelector('script')).toBeNull()
515506
})
516507

517-
it('shows the error state and sends nothing when the SDK fails to load', async () => {
508+
it('shows the error state and sends nothing when the SDK never appears', async () => {
509+
vi.useFakeTimers({ shouldAdvanceTime: true })
518510
config.wafIntegrationUrl = sdkUrl
519511

520-
const user = await submitPositiveFeedback()
512+
const user = await renderAndReact(
513+
userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
514+
)
521515
await user.click(screen.getByRole('checkbox', { name: /Accurate/ }))
522516
await user.click(screen.getByRole('button', { name: 'Submit' }))
523-
act(() => {
524-
findSdkScript()!.dispatchEvent(new Event('error'))
525-
})
517+
await act(() => vi.advanceTimersByTimeAsync(5000))
526518

527519
expect(await screen.findByRole('alert')).toBeInTheDocument()
528520
expect(global.fetch).not.toHaveBeenCalled()

‎src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { config } from '../config'
2-
import { loadWafSdk } from '../waf'
2+
import { waitForWafSdk } from '../waf'
33
import r2wc from '@r2wc/react-to-web-component'
44
import * as React from 'react'
55
import { FormEvent, useEffect, useId, useRef, useState } from 'react'
@@ -226,7 +226,7 @@ const submitFeedback = async (
226226
feedbackId: string,
227227
payload: PageFeedbackPayload
228228
) => {
229-
const waf = await loadWafSdk()
229+
const waf = await waitForWafSdk()
230230
const response = await (waf?.fetch ?? fetch)(
231231
`${config.apiBasePath}/v1/page-feedback/${feedbackId}`,
232232
{
@@ -288,11 +288,6 @@ export const PageFeedback = ({
288288

289289
useEffect(() => () => pendingReactionSaveRef.current?.(false), [])
290290

291-
useEffect(() => {
292-
// Warm the WAF token; a failure resurfaces on submit.
293-
loadWafSdk().catch(() => {})
294-
}, [])
295-
296291
const saveInitialReaction = async (nextReaction: Reaction) => {
297292
try {
298293
await submitFeedback(feedbackId, {

‎src/Elastic.Documentation.Site/Layout/_Head.cshtml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@
5858
cached copy running before the parser reaches the body throws. *@
5959
<script src="@(Model.Features.StagingElasticNavEnabled ? "https://stag-www.elastic.co/elastic-nav.js" : "https://www.elastic.co/elastic-nav.js")" defer></script>
6060
}
61+
@if (Model.FrontendConfig.WafIntegrationUrl is { } wafIntegrationUrl)
62+
{
63+
@* AWS WAF SDK: fetches the aws-waf-token cookie in the background for page feedback and
64+
telemetry exports (Assets/waf.ts). async, not defer: a deferred script delays
65+
DOMContentLoaded, so a slow WAF endpoint would hold up page init. *@
66+
<script src="@wafIntegrationUrl" async></script>
67+
}
6168
@if (Model.Features.WebsiteSearchEnabled && Model.Features.WebsiteSearchScriptUrl is { } websiteSearchUrl)
6269
{
6370
<script type="module" src="@websiteSearchUrl"></script>

‎tests/Navigation.Tests/DesignSystem/DesignSystemHeadTests.cs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,44 @@ public async Task StateMeta_AirGappedAssembler_IsStillAnAssemblerBuild()
131131
);
132132
}
133133

134+
private const string WafIntegrationUrl = "https://waf.example/jsapi.js";
135+
136+
[Test]
137+
public async Task WafSdk_AssemblerWithUrl_LoadsTheScriptAsync()
138+
{
139+
var html = await RenderHead(new FeatureFlags([]) { WafIntegrationUrl = WafIntegrationUrl }, BuildType.Assembler);
140+
141+
html.Should().Contain($"""<script src="{WafIntegrationUrl}" async></script>""");
142+
}
143+
144+
[Test]
145+
[Arguments(BuildType.Isolated)]
146+
[Arguments(BuildType.Codex)]
147+
public async Task WafSdk_OtherBuildTypes_LoadNoScript(BuildType buildType)
148+
{
149+
var html = await RenderHead(new FeatureFlags([]) { WafIntegrationUrl = WafIntegrationUrl }, buildType);
150+
151+
html.Should().NotContain(WafIntegrationUrl);
152+
}
153+
154+
[Test]
155+
public async Task WafSdk_AirGappedAssembler_LoadsNoScript()
156+
{
157+
var features = new FeatureFlags(new Dictionary<string, bool> { ["air-gapped"] = true }) { WafIntegrationUrl = WafIntegrationUrl };
158+
159+
var html = await RenderHead(features, BuildType.Assembler);
160+
161+
html.Should().NotContain(WafIntegrationUrl);
162+
}
163+
164+
[Test]
165+
public async Task WafSdk_AssemblerWithoutUrl_LoadsNoScript()
166+
{
167+
var html = await RenderHead(new FeatureFlags([]), BuildType.Assembler);
168+
169+
html.Should().NotContain("awswaf").And.NotContain("waf.example");
170+
}
171+
134172
private static string StateMeta(string html) =>
135173
string.Join('\n', Regex.Matches(html, @"<meta name=""docs:(features|build-type)""[^>]*>").Select(m => m.Value));
136174

0 commit comments

Comments
 (0)