Skip to content

Commit 3aad005

Browse files
authored
Added opt-in owner permission normalization to @tryghost/zip extract (#761)
ref https://linear.app/ghost/issue/ONC-1838 - Adds an `ensureOwnerPermissions` option (default `false`) to `extract()` - When enabled, normalizes extracted entry permissions so the owner can always read/move/remove the result: directories gain at least owner rwx and files gain at least owner rw, while existing execute/group/world bits are preserved - Fixes archives containing read-only directories (e.g. `dr-xr-xr-x`/`0555`) that otherwise fail to extract because nested files cannot be written into them - Normalization mutates only the in-memory entry handed to extract-zip; the source zip is never rewritten or chmodded, and it runs after the existing size-limit, symlink and filename checks - Defaults are unchanged for existing callers
1 parent 9ab1ad3 commit 3aad005

5 files changed

Lines changed: 366 additions & 11 deletions

File tree

packages/zip/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ let res = await zip.compress('path/to/a/folder', 'path/to/archive.zip', [options
2626
let res = await zip.extract('path/to/archive.zip', 'path/to/files', [options])
2727
```
2828

29+
### `extract` options
30+
31+
- `limits.perEntryUncompressedBytes` / `limits.totalUncompressedBytes` — reject archives whose entries exceed the given uncompressed sizes.
32+
- `onEntry(entry, zipfile)` — called for every entry before it is written.
33+
- `ensureOwnerPermissions` (default `false`) — when `true`, normalizes extracted entry permissions so the owner can always read, move and remove the result. Directories gain at least owner `rwx` and files gain at least owner `rw`, while existing execute/group/world bits are preserved. The source zip is never modified.
34+
35+
This fixes archives that contain read-only directories (for example a `dr-xr-xr-x` / `0555` folder), which otherwise fail to extract because nested files cannot be written into them. It is intended for **trusted temporary extraction** of user-supplied archives (such as theme zips) where the caller must be able to read, move and remove the extracted tree.
36+
37+
```
38+
let res = await zip.extract('path/to/upload.zip', 'path/to/tmp', {ensureOwnerPermissions: true})
39+
```
40+
2941
## Develop
3042
3143
This is a mono repository, managed with [Nx](https://nx.dev).
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
const { S_IFMT, S_IFDIR, getEntryMode } = require('./entry-mode');
2+
3+
// Owner permission bits we guarantee when `ensureOwnerPermissions` is enabled.
4+
const OWNER_DIR_BITS = 0o700; // rwx, so the tree can be traversed, written and removed
5+
const OWNER_FILE_BITS = 0o600; // rw, so files can be read and rewritten/removed
6+
7+
// Mirrors extract-zip's directory detection: POSIX directory type bit, a
8+
// trailing slash, or the Windows directory attribute.
9+
function isDirectoryEntry(entry, mode) {
10+
if ((mode & S_IFMT) === S_IFDIR) {
11+
return true;
12+
}
13+
14+
if (entry.fileName.endsWith('/')) {
15+
return true;
16+
}
17+
18+
// Windows' way of specifying a directory
19+
// https://github.com/maxogden/extract-zip/issues/13#issuecomment-154494566
20+
const madeBy = entry.versionMadeBy >> 8;
21+
return madeBy === 0 && entry.externalFileAttributes === 16;
22+
}
23+
24+
// Resolves the mode extract-zip would synthesise for an entry that carries no
25+
// POSIX mode bits, mirroring its getExtractedMode() fallback: the caller's
26+
// defaultDirMode/defaultFileMode, or 0o755/0o644 when those are not set.
27+
function resolveDefaultMode(isDir, opts) {
28+
let mode = 0;
29+
30+
if (isDir) {
31+
if (opts.defaultDirMode) {
32+
mode = parseInt(opts.defaultDirMode, 10);
33+
}
34+
35+
if (!mode) {
36+
mode = 0o755;
37+
}
38+
} else {
39+
if (opts.defaultFileMode) {
40+
mode = parseInt(opts.defaultFileMode, 10);
41+
}
42+
43+
if (!mode) {
44+
mode = 0o644;
45+
}
46+
}
47+
48+
return mode;
49+
}
50+
51+
/**
52+
* Normalises a zip entry's permissions in place so the extracting user can
53+
* always read, move and remove the result: directories gain at least owner rwx
54+
* and files at least owner rw, while existing execute/group/world bits are
55+
* preserved. Only the in-memory entry handed to extract-zip is changed; the
56+
* original zip file is never rewritten.
57+
*
58+
* @param {Object} entry - the yauzl entry extract-zip is about to extract
59+
* @param {Object} [opts] - the extract options (read for defaultDirMode/defaultFileMode)
60+
*/
61+
function ensureOwnerPermissions(entry, opts = {}) {
62+
let mode = getEntryMode(entry);
63+
const isDir = isDirectoryEntry(entry, mode);
64+
65+
// No POSIX mode bits are present, so extract-zip would synthesise the mode
66+
// from defaultDirMode/defaultFileMode (falling back to 0o755/0o644). Resolve
67+
// that same default here so the owner bits below are guaranteed even when the
68+
// caller-supplied defaults omit them.
69+
if (mode === 0) {
70+
mode = resolveDefaultMode(isDir, opts);
71+
}
72+
73+
const ownerBits = isDir ? OWNER_DIR_BITS : OWNER_FILE_BITS;
74+
let nextMode = mode | ownerBits;
75+
76+
// A directory that was only inferred (trailing slash / Windows attribute)
77+
// may lack the POSIX directory type bit. Set it so extract-zip still
78+
// classifies the rewritten mode as a directory.
79+
if (isDir && (nextMode & S_IFMT) !== S_IFDIR) {
80+
nextMode = (nextMode & ~S_IFMT) | S_IFDIR;
81+
}
82+
83+
// The mode extract-zip would already produce grants the owner the required
84+
// access and needs no type-bit fix, so leave the entry untouched.
85+
if (nextMode === mode) {
86+
return;
87+
}
88+
89+
// Write the new POSIX mode back into the high 16 bits while preserving the
90+
// low 16 bits (DOS attributes and other zip-specific flags).
91+
const lowBits = entry.externalFileAttributes & 0xffff;
92+
entry.externalFileAttributes = (((nextMode & 0xffff) << 16) | lowBits) >>> 0;
93+
}
94+
95+
module.exports = ensureOwnerPermissions;

packages/zip/lib/entry-mode.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// POSIX `stat` mode constants, matching the ones extract-zip uses internally
2+
// when it derives an entry's type and permissions from its external attributes.
3+
const S_IFMT = 0o170000; // bit mask for the file type bit fields
4+
const S_IFDIR = 0o040000; // directory
5+
const S_IFLNK = 0o120000; // symbolic link
6+
7+
// Reads the POSIX mode an entry carries in the high 16 bits of its external
8+
// file attributes, exactly as extract-zip does when extracting.
9+
function getEntryMode(entry) {
10+
return (entry.externalFileAttributes >> 16) & 0xffff;
11+
}
12+
13+
module.exports = {
14+
S_IFMT,
15+
S_IFDIR,
16+
S_IFLNK,
17+
getEntryMode,
18+
};

packages/zip/lib/extract.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const errors = require('@tryghost/errors');
2+
const { S_IFMT, S_IFLNK, getEntryMode } = require('./entry-mode');
3+
const ensureOwnerPermissions = require('./ensure-owner-permissions');
24

35
const defaultOptions = {};
46

@@ -106,12 +108,9 @@ function throwOnTotalTooLarge(entry, observedBytes, limitBytes, entriesProcessed
106108
}
107109

108110
function throwOnSymlinks(entry) {
109-
// Check if symlink
110-
const mode = (entry.externalFileAttributes >> 16) & 0xffff;
111-
// check if it's a symlink or dir (using stat mode constants)
112-
const IFMT = 61440;
113-
const IFLNK = 40960;
114-
const symlink = (mode & IFMT) === IFLNK;
111+
// Check if symlink (using stat mode constants)
112+
const mode = getEntryMode(entry);
113+
const symlink = (mode & S_IFMT) === S_IFLNK;
115114

116115
if (symlink) {
117116
throw new errors.UnsupportedMediaTypeError({
@@ -150,12 +149,16 @@ function throwOnLargeFilenames(entry) {
150149
* @param {String} zipToExtract - full path to zip file that should be extracted
151150
* @param {String} destination - full path of the extraction target
152151
* @param {Object} [options]
153-
* @param {Integer} options.defaultDirMode - Directory Mode (permissions), defaults to 0o755
154-
* @param {Integer} options.defaultFileMode - File Mode (permissions), defaults to 0o644
155-
* @param {Function} options.onEntry - if present, will be called with (entry, zipfile) for every entry in the zip
152+
* @param {Integer} [options.defaultDirMode] - Directory Mode (permissions), defaults to 0o755
153+
* @param {Integer} [options.defaultFileMode] - File Mode (permissions), defaults to 0o644
154+
* @param {Function} [options.onEntry] - if present, will be called with (entry, zipfile) for every entry in the zip
156155
* @param {Object} [options.limits] - if present, sets maximum uncompressed sizes
157-
* @param {Integer} options.limits.perEntryUncompressedBytes - maximum uncompressed size of each entry
158-
* @param {Integer} options.limits.totalUncompressedBytes - maximum total uncompressed size across all entries
156+
* @param {Integer} [options.limits.perEntryUncompressedBytes] - maximum uncompressed size of each entry
157+
* @param {Integer} [options.limits.totalUncompressedBytes] - maximum total uncompressed size across all entries
158+
* @param {Boolean} [options.ensureOwnerPermissions] - when true, normalizes extracted entry permissions so the
159+
* owner can always read/move/remove the result: directories gain at least owner rwx and files at least owner rw,
160+
* while existing execute/group/world bits are preserved. The source zip is never modified. Defaults to false.
161+
* Intended for trusted temporary extraction of user-supplied archives (e.g. theme zips with read-only directories).
159162
*/
160163
module.exports = async (zipToExtract, destination, options) => {
161164
const opts = Object.assign({}, defaultOptions, options);
@@ -167,6 +170,7 @@ module.exports = async (zipToExtract, destination, options) => {
167170

168171
opts.dir = destination;
169172

173+
const shouldEnsureOwnerPermissions = opts.ensureOwnerPermissions === true;
170174
const originalOnEntry = opts.onEntry;
171175
opts.onEntry = (entry, zipfile) => {
172176
const entryUncompressedBytes = getEntryUncompressedSize(entry, limits);
@@ -188,6 +192,12 @@ module.exports = async (zipToExtract, destination, options) => {
188192
if (originalOnEntry) {
189193
originalOnEntry(entry, zipfile);
190194
}
195+
196+
// Normalize permissions last, immediately before extract-zip writes the
197+
// entry. Do not add async work here: extract-zip does not await onEntry.
198+
if (shouldEnsureOwnerPermissions) {
199+
ensureOwnerPermissions(entry, opts);
200+
}
191201
};
192202

193203
await extract(zipToExtract, opts);

0 commit comments

Comments
 (0)