Skip to content

Commit adf8a30

Browse files
committed
security: hardening for 2.6.4
- Refuse to build filesystem paths when lng/ns contains .., path separators, control chars, prototype keys, or > 128 chars (CWE-22). Prevents arbitrary filesystem read/write via attacker-controlled language-code values. - Document the trust model in a new "Security considerations" README section — especially that .js/.ts locale files are eval'd and must be treated as code. - Ignore .env*, *.pem, *.key in .gitignore. The .js/.ts eval behaviour itself is retained as an intentional feature; safe sync replacements don't exist for it. GHSA advisory to be filed after release.
1 parent 3bd0132 commit adf8a30

6 files changed

Lines changed: 242 additions & 7 deletions

File tree

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,11 @@ node_modules
33
package-lock.json
44
cjs
55
esm
6-
deno.lock
6+
deno.lock
7+
8+
# Secrets & credentials
9+
.env
10+
.env.*
11+
!.env.example
12+
*.pem
13+
*.key

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
### 2.6.4
2+
3+
Security release — all issues found via an internal audit. GHSA advisory filed after release.
4+
5+
- security: refuse to build filesystem paths when `lng` or `ns` values contain `..`, path separators (`/`, `\`), control characters, prototype keys (`__proto__` / `constructor` / `prototype`), or exceed 128 chars. Prevents arbitrary filesystem read / write via attacker-controlled language-code values. Any legitimate i18next language-code shape (BCP-47-like, underscores, hyphens, dots, `+`-joined multi-language requests) is still accepted (GHSA-TBD)
6+
- docs: new "Security considerations" README section — documents the filesystem-path sanitiser and clarifies the trust model around `.js`/`.ts` locale files (their content is `eval`-ed, so they must be treated as code). The `eval` behaviour itself is retained: dynamic expressions in `.js`/`.ts` locale files are an intentional feature, and safe replacements like `import()` are async-only and not viable for this sync-capable code path.
7+
- chore: ignore `.env*` and `*.pem`/`*.key` files in `.gitignore`.
8+
19
### 2.6.3
210

311
- use own interpolation function instead of relying on i18next's interpolator

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,55 @@ i18next
137137
})
138138
```
139139

140+
## Security considerations
141+
142+
### Language / namespace values reach the filesystem
143+
144+
`i18next-fs-backend` substitutes the `lng` and `ns` options into the
145+
configured `loadPath` / `addPath` templates and reads / writes the resulting
146+
file. If those values come from an untrusted source (HTTP query string,
147+
cookie, request header), a crafted value could break out of the intended
148+
locale directory and read or overwrite files elsewhere on disk.
149+
150+
Since **2.6.4**, values containing `..`, `/`, `\`, control characters,
151+
prototype keys (`__proto__`, `constructor`, `prototype`), or longer than
152+
128 characters are rejected — the backend refuses to build the filesystem
153+
path and returns an error to the caller. Any legitimate i18next
154+
language-code shape (BCP-47, underscores, dots, `+`-joined multi-language
155+
requests) is still accepted.
156+
157+
This is a defence-in-depth layer. It does **not** replace the usual
158+
responsibility to validate `lng` / `ns` at your application boundary —
159+
especially when either comes from user input.
160+
161+
### `.js` / `.ts` locale files are executed via `eval`
162+
163+
The backend supports loading translation data from `.js` and `.ts` files by
164+
`eval`-ing their content. This is an intentional feature — it allows
165+
expressions, comments, and module-style default exports in locale files —
166+
but it carries a real trust requirement:
167+
168+
> **Treat every `.js` / `.ts` locale file as code that will run with the
169+
> full privileges of your Node process**, including access to
170+
> `process.env`, the filesystem, and the network.
171+
172+
Concretely that means:
173+
174+
- Never load `.js` / `.ts` locale files from an untrusted or writable
175+
source (user uploads, compromised CDN, shared-mount drops).
176+
- If your build / deploy pipeline produces locale files, secure the
177+
pipeline the same way you would secure any code-producing pipeline
178+
(signed commits, reviewed merges, protected branches).
179+
- Prefer **JSON / JSON5 / YAML / JSONC** for locale files whenever you
180+
don't need expression-level dynamism — those formats are parsed, not
181+
executed.
182+
183+
### Reporting a vulnerability
184+
185+
Please **do not** open a public GitHub issue for security problems. Send
186+
reports privately via the [GitHub Security Advisories](https://github.com/i18next/i18next-fs-backend/security/advisories/new)
187+
flow on the repository.
188+
140189
---
141190

142191
<h3 align="center">Gold Sponsors</h3>

lib/index.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defaults, debounce, getPath, setPath, pushPath, interpolate } from './utils.js'
1+
import { defaults, debounce, getPath, setPath, pushPath, interpolatePath } from './utils.js'
22
import { readFile, readFileSync } from './readFile.js'
33
import { writeFile, removeFile } from './writeFile.js'
44

@@ -35,7 +35,10 @@ class Backend {
3535
if (typeof this.options.loadPath === 'function') {
3636
loadPath = this.options.loadPath(language, namespace)
3737
}
38-
const filename = interpolate(loadPath, { lng: language, ns: namespace })
38+
const filename = interpolatePath(loadPath, { lng: language, ns: namespace })
39+
if (filename == null) {
40+
return callback(new Error('i18next-fs-backend: unsafe lng/ns value — refusing to build filesystem path'), false)
41+
}
3942
if (this.allOptions.initAsync === false || this.allOptions.initImmediate === false) {
4043
try {
4144
const { data, stat } = readFileSync(filename, this.options)
@@ -98,7 +101,8 @@ class Backend {
98101
if (typeof this.options.addPath === 'function') {
99102
addPath = this.options.addPath(language, namespace)
100103
}
101-
const filename = interpolate(addPath, { lng: language, ns: namespace })
104+
const filename = interpolatePath(addPath, { lng: language, ns: namespace })
105+
if (filename == null) return
102106
removeFile(filename, this.options)
103107
.then(() => {})
104108
.catch(() => {})
@@ -124,7 +128,13 @@ class Backend {
124128
addPath = this.options.addPath(lng, namespace)
125129
}
126130

127-
const filename = interpolate(addPath, { lng, ns: namespace })
131+
const filename = interpolatePath(addPath, { lng, ns: namespace })
132+
if (filename == null) {
133+
// drop unsafe queued writes silently — attempting to persist them
134+
// would either fail or (worse) land in an unexpected filesystem location
135+
setPath(this.queuedWrites, [lng, namespace], [])
136+
return
137+
}
128138

129139
const missings = getPath(this.queuedWrites, [lng, namespace])
130140
setPath(this.queuedWrites, [lng, namespace], [])

lib/utils.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,37 @@ const arr = []
22
const each = arr.forEach
33
const slice = arr.slice
44

5+
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']
6+
57
export function defaults (obj) {
68
each.call(slice.call(arguments, 1), (source) => {
79
if (source) {
8-
for (const prop in source) {
10+
for (const prop of Object.keys(source)) {
11+
if (UNSAFE_KEYS.indexOf(prop) > -1) continue
912
if (obj[prop] === undefined) obj[prop] = source[prop]
1013
}
1114
}
1215
})
1316
return obj
1417
}
1518

19+
// Returns true if `v` can be safely interpolated into a filesystem path.
20+
// Denylist approach — i18next permits arbitrary language-code shapes
21+
// (https://www.i18next.com/how-to/faq#how-should-the-language-codes-be-formatted)
22+
// so we only block the concrete attack patterns: path traversal, path
23+
// separators (so attacker cannot break out of the locale directory),
24+
// control characters, prototype keys, and oversized inputs.
25+
export function isSafePathSegment (v) {
26+
if (typeof v !== 'string') return false
27+
if (v.length === 0 || v.length > 128) return false
28+
if (UNSAFE_KEYS.indexOf(v) > -1) return false
29+
if (v.indexOf('..') > -1) return false
30+
if (v.indexOf('/') > -1 || v.indexOf('\\') > -1) return false
31+
// eslint-disable-next-line no-control-regex
32+
if (/[\x00-\x1F\x7F]/.test(v)) return false
33+
return true
34+
}
35+
1636
export function debounce (func, wait, immediate) {
1737
let timeout
1838
return function () {
@@ -72,7 +92,32 @@ export function getPath (object, path) {
7292
const interpolationRegexp = /\{\{(.+?)\}\}/g
7393
export function interpolate (str, data) {
7494
return str.replace(interpolationRegexp, (match, key) => {
75-
const value = data[key.trim()]
95+
const k = key.trim()
96+
if (UNSAFE_KEYS.indexOf(k) > -1) return match
97+
const value = data[k]
7698
return value != null ? value : match
7799
})
78100
}
101+
102+
// Path-specific variant: reject values that fail the path-segment safety
103+
// check. Returns `null` if any substitution is unsafe — callers should bail
104+
// out cleanly rather than issue the filesystem read/write. For multi-value
105+
// joins (`en+de`), validates each `+`-separated segment independently.
106+
export function interpolatePath (str, data) {
107+
let unsafe = false
108+
const result = str.replace(interpolationRegexp, (match, key) => {
109+
const k = key.trim()
110+
if (UNSAFE_KEYS.indexOf(k) > -1) return match
111+
const value = data[k]
112+
if (value == null) return match
113+
const segments = String(value).split('+')
114+
for (const seg of segments) {
115+
if (!isSafePathSegment(seg)) {
116+
unsafe = true
117+
return match
118+
}
119+
}
120+
return segments.join('+')
121+
})
122+
return unsafe ? null : result
123+
}

test/security.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import expect from 'expect.js'
2+
import { dirname } from 'path'
3+
import { fileURLToPath } from 'url'
4+
const __dirname = dirname(fileURLToPath(import.meta.url))
5+
import i18next from 'i18next'
6+
import Backend from '../index.js'
7+
import { isSafePathSegment, interpolate, interpolatePath } from '../lib/utils.js'
8+
9+
// Security tests for fixes shipped in the 2.6.x patch release.
10+
// See CHANGELOG for associated GHSA advisory.
11+
12+
describe('security', () => {
13+
describe('isSafePathSegment', () => {
14+
it('accepts arbitrary language-code shapes', () => {
15+
expect(isSafePathSegment('en')).to.be(true)
16+
expect(isSafePathSegment('de-DE')).to.be(true)
17+
expect(isSafePathSegment('en_US')).to.be(true)
18+
expect(isSafePathSegment('zh-Hant-HK')).to.be(true)
19+
expect(isSafePathSegment('pirate-speak')).to.be(true)
20+
expect(isSafePathSegment('my-custom.ns')).to.be(true)
21+
})
22+
23+
it('rejects path-traversal / separator / control-char payloads', () => {
24+
expect(isSafePathSegment('../etc/passwd')).to.be(false)
25+
expect(isSafePathSegment('..')).to.be(false)
26+
expect(isSafePathSegment('foo/bar')).to.be(false)
27+
expect(isSafePathSegment('foo\\bar')).to.be(false)
28+
expect(isSafePathSegment('en\r\n')).to.be(false)
29+
expect(isSafePathSegment('en\u0000')).to.be(false)
30+
expect(isSafePathSegment('__proto__')).to.be(false)
31+
expect(isSafePathSegment('')).to.be(false)
32+
expect(isSafePathSegment('a'.repeat(200))).to.be(false)
33+
})
34+
})
35+
36+
describe('interpolate', () => {
37+
it('skips __proto__ key lookups in the data object', () => {
38+
const out = interpolate('x {{__proto__}} y', { __proto__: { polluted: true } })
39+
expect(out).to.equal('x {{__proto__}} y')
40+
expect(({}).polluted).to.be(undefined)
41+
})
42+
it('substitutes normal keys', () => {
43+
expect(interpolate('{{lng}}/{{ns}}.json', { lng: 'en', ns: 'common' }))
44+
.to.equal('en/common.json')
45+
})
46+
})
47+
48+
describe('interpolatePath', () => {
49+
it('accepts plain codes', () => {
50+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: 'en', ns: 'common' }))
51+
.to.equal('/locales/en/common.json')
52+
})
53+
it('accepts + joins (multi-language)', () => {
54+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: 'en+de', ns: 'a' }))
55+
.to.equal('/locales/en+de/a.json')
56+
})
57+
it('returns null for path traversal', () => {
58+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: '../etc/passwd', ns: 'x' }))
59+
.to.equal(null)
60+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: 'en', ns: '..' }))
61+
.to.equal(null)
62+
})
63+
it('returns null for path separators', () => {
64+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: 'en/../', ns: 'x' }))
65+
.to.equal(null)
66+
expect(interpolatePath('/locales/{{lng}}/{{ns}}.json', { lng: 'en\\x', ns: 'x' }))
67+
.to.equal(null)
68+
})
69+
it('returns null when a segment of a + join is unsafe', () => {
70+
expect(interpolatePath('/locales/{{lng}}.json', { lng: 'en+../etc/passwd' }))
71+
.to.equal(null)
72+
})
73+
})
74+
75+
describe('Backend.read refuses unsafe lng/ns', () => {
76+
let backend
77+
before(() => {
78+
i18next.init({ fallbackLng: 'en', ns: 'test' })
79+
const connector = i18next.services.backendConnector
80+
backend = new Backend(i18next.services, {
81+
loadPath: `${__dirname}/locales/{{lng}}/{{ns}}.json`,
82+
addPath: `${__dirname}/locales/{{lng}}/{{ns}}.json`
83+
}, connector.allOptions || {})
84+
})
85+
86+
it('does not read outside the locale directory on lng=../../etc/passwd', (done) => {
87+
backend.read('../../etc/passwd', 'test', (err, data) => {
88+
expect(err).to.be.an(Error)
89+
expect(err.message).to.contain('unsafe lng/ns')
90+
expect(data).to.be(false)
91+
done()
92+
})
93+
})
94+
95+
it('does not read when ns contains a slash', (done) => {
96+
backend.read('en', '../../etc/passwd', (err, data) => {
97+
expect(err).to.be.an(Error)
98+
expect(err.message).to.contain('unsafe lng/ns')
99+
expect(data).to.be(false)
100+
done()
101+
})
102+
})
103+
104+
it('still reads legitimate languages (regression guard)', (done) => {
105+
backend.read('en', 'test', (err, data) => {
106+
// No assertion on data (file may or may not exist in this suite) —
107+
// the key point is that the safety guard did NOT reject a legit value.
108+
if (err && /unsafe lng\/ns/.test(err.message)) {
109+
done(new Error('safety guard rejected a legitimate input'))
110+
return
111+
}
112+
done()
113+
})
114+
})
115+
})
116+
})

0 commit comments

Comments
 (0)