Skip to content

Commit ca1d7e4

Browse files
author
Peter Bengtsson
authored
Shielding subject folder (github#37690)
1 parent 0f92b41 commit ca1d7e4

File tree

8 files changed

+96
-11
lines changed

8 files changed

+96
-11
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
{ name: 'rest', path: 'src/rest/tests', },
5858
{ name: 'routing', path: 'tests/routing', },
5959
{ name: 'search', path: 'src/search/tests', },
60+
{ name: 'shielding', path: 'src/shielding/tests', },
6061
context.payload.repository.full_name === 'github/docs-internal' &&
6162
{ name: 'translations', path: 'tests/translations', },
6263
{ name: 'unit', path: 'tests/unit', },

middleware/index.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
setLanguageFastlySurrogateKey,
1717
} from './set-fastly-surrogate-key.js'
1818
import handleErrors from '#src/observability/middleware/handle-errors.js'
19-
import handleInvalidPaths from '#src/observability/middleware/handle-invalid-paths.js'
2019
import handleNextDataPath from './handle-next-data-path.js'
2120
import detectLanguage from './detect-language.js'
2221
import reloadTree from './reload-tree.js'
@@ -66,8 +65,7 @@ import fastlyBehavior from './fastly-behavior.js'
6665
import mockVaPortal from './mock-va-portal.js'
6766
import dynamicAssets from './dynamic-assets.js'
6867
import contextualizeSearch from '#src/search/middleware/contextualize.js'
69-
import rateLimit from './rate-limit.js'
70-
import handleInvalidQuerystrings from '#src/observability/middleware/handle-invalid-query-strings.js'
68+
import shielding from '#src/shielding/middleware/index.js'
7169

7270
const { DEPLOYMENT_ENV, NODE_ENV } = process.env
7371
const isTest = NODE_ENV === 'test' || process.env.GITHUB_ACTIONS === 'true'
@@ -201,9 +199,7 @@ export default function (app) {
201199
}
202200

203201
// *** Early exits ***
204-
app.use(rateLimit)
205-
app.use(instrument(handleInvalidQuerystrings, './handle-invalid-querystrings'))
206-
app.use(instrument(handleInvalidPaths, './handle-invalid-paths'))
202+
app.use(shielding)
207203
app.use(instrument(handleNextDataPath, './handle-next-data-path'))
208204

209205
// *** Security ***
File renamed without changes.

src/observability/middleware/handle-invalid-query-strings.js renamed to src/shielding/middleware/handle-invalid-query-strings.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import statsd from '../lib/statsd.js'
1+
import statsd from '#src/observability/lib/statsd.js'
22
import { noCacheControl, defaultCacheControl } from '../../../middleware/cache-control.js'
33

44
const STATSD_KEY = 'middleware.handle_invalid_querystrings'

src/shielding/middleware/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import express from 'express'
2+
3+
import handleInvalidQuerystrings from './handle-invalid-query-strings.js'
4+
import handleInvalidPaths from './handle-invalid-paths.js'
5+
import rateLimit from './rate-limit.js'
6+
7+
const router = express.Router()
8+
9+
router.use(rateLimit)
10+
router.use(handleInvalidQuerystrings)
11+
router.use(handleInvalidPaths)
12+
13+
export default router
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import rateLimit from 'express-rate-limit'
22

33
import statsd from '#src/observability/lib/statsd.js'
4-
import { noCacheControl } from './cache-control.js'
4+
import { noCacheControl } from '../../../middleware/cache-control.js'
55

66
const EXPIRES_IN_AS_SECONDS = 60
77

tests/rendering-fixtures/invalid-querystrings.js renamed to src/shielding/tests/invalid-querystrings.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { describe } from '@jest/globals'
1+
import { get } from '../../../tests/helpers/e2etest.js'
22

3-
import { get } from '../helpers/e2etest.js'
43
import {
54
MAX_UNFAMILIAR_KEYS_BAD_REQUEST,
65
MAX_UNFAMILIAR_KEYS_REDIRECT,
7-
} from '#src/observability/middleware/handle-invalid-query-strings.js'
6+
} from '#src/shielding/middleware/handle-invalid-query-strings.js'
87

98
const alpha = Array.from(Array(26)).map((e, i) => i + 65)
109
const alphabet = alpha.map((x) => String.fromCharCode(x))

src/shielding/tests/shielding.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { get } from '../../../tests/helpers/e2etest.js'
2+
3+
describe('honeypotting', () => {
4+
test('any GET with survey-vote and survey-token query strings is 400', async () => {
5+
const res = await get('/en?survey-vote=1&survey-token=2')
6+
expect(res.statusCode).toBe(400)
7+
expect(res.body).toMatch(/Honeypotted/)
8+
expect(res.headers['cache-control']).toMatch('private')
9+
})
10+
})
11+
12+
describe('junk paths', () => {
13+
test('junk full pathname', async () => {
14+
const res = await get('/xmlrpc.php')
15+
expect(res.statusCode).toBe(404)
16+
expect(res.headers['content-type']).toMatch('text/plain')
17+
expect(res.headers['cache-control']).toMatch('public')
18+
})
19+
20+
test('junk base name', async () => {
21+
const res = await get('/en/get-started/.env.local')
22+
expect(res.statusCode).toBe(404)
23+
expect(res.headers['content-type']).toMatch('text/plain')
24+
expect(res.headers['cache-control']).toMatch('public')
25+
})
26+
27+
test.each(['/_nextanything', '/_next/data', '/_next/data/'])(
28+
'invalid requests for _next prefix %s',
29+
async (path) => {
30+
const res = await get(path)
31+
expect(res.statusCode).toBe(404)
32+
expect(res.headers['content-type']).toMatch('text/plain')
33+
expect(res.headers['cache-control']).toMatch('public')
34+
}
35+
)
36+
37+
test('any URL that ends with /index.md redirects', async () => {
38+
const res = await get('/en/get-started/index.md')
39+
expect(res.statusCode).toBe(302)
40+
expect(res.headers.location).toBe('/en/get-started')
41+
})
42+
})
43+
44+
describe('rate limiting', () => {
45+
// We can't actually trigger a full rate limit because
46+
// then all other tests will all fail. And we can't rely on this
47+
// test always being run last.
48+
49+
test('only happens if you have junk query strings', async () => {
50+
const res = await get('/robots.txt?foo=bar')
51+
expect(res.statusCode).toBe(200)
52+
const limit = parseInt(res.headers['ratelimit-limit'])
53+
const remaining = parseInt(res.headers['ratelimit-remaining'])
54+
expect(limit).toBeGreaterThan(0)
55+
expect(remaining).toBeLessThan(limit)
56+
57+
// A second request
58+
{
59+
const res = await get('/robots.txt?foo=buzz')
60+
expect(res.statusCode).toBe(200)
61+
const newLimit = parseInt(res.headers['ratelimit-limit'])
62+
const newRemaining = parseInt(res.headers['ratelimit-remaining'])
63+
expect(newLimit).toBe(limit)
64+
// Can't rely on `newRemaining == remaining - 1` because of
65+
// concurrency of jest-running.
66+
expect(newRemaining).toBeLessThan(remaining)
67+
}
68+
})
69+
70+
test('nothing happens if no unrecognized query string', async () => {
71+
const res = await get('/robots.txt')
72+
expect(res.statusCode).toBe(200)
73+
expect(res.headers['ratelimit-limit']).toBeUndefined()
74+
expect(res.headers['ratelimit-remaining']).toBeUndefined()
75+
})
76+
})

0 commit comments

Comments
 (0)