Skip to content

Commit

Permalink
feat: use netlify-headers-parser
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Aug 16, 2021
1 parent 76513d4 commit e37bae7
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 112 deletions.
56 changes: 50 additions & 6 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"minimist": "^1.2.5",
"multiparty": "^4.2.1",
"netlify": "^8.0.0",
"netlify-headers-parser": "^3.0.0",
"netlify-redirect-parser": "^11.0.1",
"netlify-redirector": "^0.2.1",
"node-fetch": "^2.6.0",
Expand Down
80 changes: 24 additions & 56 deletions src/utils/headers.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
const fs = require('fs')

const { get } = require('dot-prop')
const escapeRegExp = require('lodash/escapeRegExp')
const trimEnd = require('lodash/trimEnd')
const { parseAllHeaders } = require('netlify-headers-parser')

const TOKEN_COMMENT = '#'
const TOKEN_PATH = '/'
const { NETLIFYDEVWARN } = require('./logo')

// Our production logic uses regex too
const getRulePattern = (rule) => {
const ruleParts = rule.split('/').filter(Boolean)
const getRulePattern = (forPath) => {
const ruleParts = forPath.split('/').filter(Boolean)
if (ruleParts.length === 0) {
return `^/$`
}
Expand Down Expand Up @@ -59,67 +56,38 @@ const matchesPath = (rule, path) => {
* @returns {Object<string,string[]>}
*/
const headersForPath = function (rules, path) {
const matchingHeaders = Object.entries(rules)
.filter(([rule]) => matchesPath(rule, path))
.map(([, headers]) => headers)

const matchingHeaders = rules.filter(({ for: forPath }) => matchesPath(forPath, path)).map(getHeaderValues)
const pathObject = Object.assign({}, ...matchingHeaders)
return pathObject
}

const HEADER_SEPARATOR = ':'

const parseHeadersFile = function (filePath) {
if (!fs.existsSync(filePath)) {
return {}
}
if (fs.statSync(filePath).isDirectory()) {
console.warn('expected _headers file but found a directory at:', filePath)
return {}
}

const lines = fs
.readFileSync(filePath, { encoding: 'utf8' })
.split('\n')
.map((line, index) => ({ line: line.trim(), index }))
.filter(({ line }) => Boolean(line) && !line.startsWith(TOKEN_COMMENT))
const getHeaderValues = function ({ values }) {
return values
}

let path
let rules = {}
// eslint-disable-next-line fp/no-loops
for (const { line, index } of lines) {
if (line.startsWith(TOKEN_PATH)) {
path = line
continue
}
const parseHeaders = async function ({ headersFiles }) {
const { headers, errors } = await parseAllHeaders({ headersFiles })
handleHeadersErrors(errors)
return headers
}

if (!path) {
throw new Error('path should come before headers')
}
const handleHeadersErrors = function (errors) {
if (errors.length === 0) {
return
}

if (line.includes(HEADER_SEPARATOR)) {
const [key = '', ...value] = line.split(HEADER_SEPARATOR)
const [trimmedKey, trimmedValue] = [key.trim(), value.join(HEADER_SEPARATOR).trim()]
if (trimmedKey.length === 0 || trimmedValue.length === 0) {
throw new Error(`invalid header at line: ${index}\n${line}\n`)
}
const errorMessage = errors.map(getErrorMessage).join('\n\n')
throw new Error(`${NETLIFYDEVWARN} Warnings while parsing headers:
const currentHeaders = get(rules, `${path}.${trimmedKey}`) || []
rules = {
...rules,
[path]: {
...rules[path],
[trimmedKey]: [...currentHeaders, trimmedValue],
},
}
}
}
${errorMessage}`)
}

return rules
const getErrorMessage = function ({ message }) {
return message
}

module.exports = {
matchesPath,
headersForPath,
parseHeadersFile,
parseHeaders,
}
111 changes: 66 additions & 45 deletions src/utils/headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const test = require('ava')

const { createSiteBuilder } = require('../../tests/utils/site-builder')

const { parseHeadersFile, headersForPath, matchesPath } = require('./headers')
const { parseHeaders, headersForPath, matchesPath } = require('./headers')

const headers = [
{ path: '/', headers: ['X-Frame-Options: SAMEORIGIN'] },
Expand Down Expand Up @@ -70,77 +70,98 @@ test.after(async (t) => {
/**
* Pass if we can load the test headers without throwing an error.
*/
test('_headers: syntax validates as expected', (t) => {
parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
test('_headers: syntax validates as expected', async (t) => {
await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
})

test('_headers: throws on invalid syntax', (t) => {
t.throws(() => parseHeadersFile(path.resolve(t.context.builder.directory, '_invalid_headers')), {
message: /invalid header at line: 5/,
test('_headers: throws on invalid syntax', async (t) => {
await t.throwsAsync(parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_invalid_headers')] }), {
message: /Missing header value/,
})
})

test('_headers: validate rules', (t) => {
const rules = parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
t.deepEqual(rules, {
'/': {
'X-Frame-Options': ['SAMEORIGIN'],
test('_headers: validate rules', async (t) => {
const rules = await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
t.deepEqual(rules, [
{
for: '/',
values: {
'X-Frame-Options': 'SAMEORIGIN',
},
},
'/*': {
'X-Frame-Thing': ['SAMEORIGIN'],
{
for: '/*',
values: {
'X-Frame-Thing': 'SAMEORIGIN',
},
},
'/static-path/*': {
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
{
for: '/static-path/*',
values: {
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
},
},
'/:placeholder/index.html': {
'X-Frame-Options': ['SAMEORIGIN'],
{
for: '/:placeholder/index.html',
values: {
'X-Frame-Options': 'SAMEORIGIN',
},
},
'/*/_next/static/chunks/*': {
'cache-control': ['public', 'max-age=31536000', 'immutable'],
{
for: '/*/_next/static/chunks/*',
values: {
'cache-control': 'public,max-age=31536000,immutable',
},
},
'/directory/*/test.html': {
'X-Frame-Options': ['test'],
{
for: '/directory/*/test.html',
values: {
'X-Frame-Options': 'test',
},
},
'/with-colon': {
'Custom-header': ['http://www.example.com'],
{
for: '/with-colon',
values: {
'Custom-header': 'http://www.example.com',
},
},
})
])
})

test('_headers: headersForPath testing', (t) => {
const rules = parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
test('_headers: headersForPath testing', async (t) => {
const rules = await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
t.deepEqual(headersForPath(rules, '/'), {
'X-Frame-Options': ['SAMEORIGIN'],
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': 'SAMEORIGIN',
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/static-path/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
})
t.deepEqual(headersForPath(rules, '/static-path'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
})
t.deepEqual(headersForPath(rules, '/placeholder/index.html'), {
'X-Frame-Options': ['SAMEORIGIN'],
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': 'SAMEORIGIN',
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/placeholder/_next/static/chunks/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'cache-control': ['public', 'max-age=31536000', 'immutable'],
'X-Frame-Thing': 'SAMEORIGIN',
'cache-control': 'public,max-age=31536000,immutable',
})
t.deepEqual(headersForPath(rules, '/directory/placeholder/test.html'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['test'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'test',
})
})

Expand Down
Loading

0 comments on commit e37bae7

Please sign in to comment.