143 lines
6.8 KiB
TypeScript
143 lines
6.8 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from '../src/lib/server/git-url-safety';
|
|
import { containedPath } from '../src/lib/server/git-deletions';
|
|
|
|
describe('assertSafeRepoUrl — rejects dangerous URLs, allows every legit scheme', () => {
|
|
// Legitimate URLs must all pass unchanged (backward compatibility).
|
|
const legit = [
|
|
'https://github.com/user/repo.git',
|
|
'http://gitea.local/user/repo.git',
|
|
'https://user:tok@github.com/user/repo.git',
|
|
'ssh://git@gitea.example.com:2222/user/repo.git',
|
|
'git@github.com:user/repo.git',
|
|
'git://example.com/repo.git',
|
|
];
|
|
for (const url of legit) {
|
|
test(`allows ${url}`, () => {
|
|
expect(() => assertSafeRepoUrl(url)).not.toThrow();
|
|
});
|
|
}
|
|
|
|
// Dangerous transports / option injection / local paths must be rejected.
|
|
const bad = [
|
|
"ext::sh -c 'id'", // RCE via ext:: transport
|
|
'EXT::sh -c whoami', // case-insensitive
|
|
'fd::17', // fd:: transport
|
|
'file:///etc/passwd', // local file transport
|
|
'--upload-pack=/bin/sh', // parsed as a git option
|
|
'-oProxyCommand=sh', // leading dash = option
|
|
'/etc/passwd', // absolute local path
|
|
'./local/repo', // relative local path
|
|
'../escape', // parent path
|
|
'', // empty
|
|
' ', // blank
|
|
];
|
|
for (const url of bad) {
|
|
test(`rejects ${JSON.stringify(url)}`, () => {
|
|
expect(() => assertSafeRepoUrl(url)).toThrow();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('assertSafeGitRef — rejects option-like refs, allows real branch names', () => {
|
|
for (const ref of ['main', 'develop', 'feature/x', 'release-1.2', 'v1.0.0', '', null, undefined]) {
|
|
test(`allows ${JSON.stringify(ref)}`, () => {
|
|
expect(() => assertSafeGitRef(ref as any)).not.toThrow();
|
|
});
|
|
}
|
|
for (const ref of ['--upload-pack=sh', '-oProxyCommand=x', '--foo']) {
|
|
test(`rejects ${JSON.stringify(ref)}`, () => {
|
|
expect(() => assertSafeGitRef(ref)).toThrow();
|
|
});
|
|
}
|
|
});
|
|
|
|
// containedPath is the STRICT segment-ban guard used by the deletion-sync logic
|
|
// (git-deletions.ts) where banning any '.'/'..' segment outright is deliberate. It is
|
|
// NO LONGER the compose/env path guard — that moved to repoFilePath (below).
|
|
describe('containedPath — deletion-sync strict guard (bans any . / .. segment)', () => {
|
|
const root = '/app/data/git-repos/env/stack';
|
|
|
|
test('a normal subdir path resolves inside the repo', () => {
|
|
expect(containedPath(root, 'docker-compose.yml')).toBe(`${root}/docker-compose.yml`);
|
|
expect(containedPath(root, 'sub/dir/compose.yaml')).toBe(`${root}/sub/dir/compose.yaml`);
|
|
});
|
|
|
|
test('traversal out of the repo returns null (would be refused)', () => {
|
|
expect(containedPath(root, '../../../../etc/passwd')).toBeNull();
|
|
expect(containedPath(root, '..')).toBeNull();
|
|
expect(containedPath(root, 'a/../../b')).toBeNull();
|
|
});
|
|
|
|
test('an absolute path is refused', () => {
|
|
expect(containedPath(root, '/etc/passwd')).toBeNull();
|
|
});
|
|
|
|
test('empty / dot segments are refused (strict — deletion-sync semantics)', () => {
|
|
expect(containedPath(root, '')).toBeNull();
|
|
expect(containedPath(root, '.')).toBeNull();
|
|
expect(containedPath(root, './x')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// repoFilePath is the compose/env path guard. Unlike containedPath it uses
|
|
// resolve-then-containment (like a plain join + escape check): an in-repo `..` and a
|
|
// leading `./` still resolve (backward-compat), only an ESCAPING path throws.
|
|
describe('repoFilePath — compose/env path guard (resolve-then-containment)', () => {
|
|
const root = '/app/data/git-repos/env/stack';
|
|
|
|
test('plain in-repo paths resolve (unchanged)', () => {
|
|
expect(repoFilePath(root, 'docker-compose.yml', 'Compose path')).toBe(`${root}/docker-compose.yml`);
|
|
expect(repoFilePath(root, 'sub/dir/compose.yaml', 'Compose path')).toBe(`${root}/sub/dir/compose.yaml`);
|
|
});
|
|
|
|
test('a leading ./ resolves', () => {
|
|
expect(repoFilePath(root, './compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
|
|
expect(repoFilePath(root, './stacks/compose.yml', 'Compose path')).toBe(`${root}/stacks/compose.yml`);
|
|
expect(repoFilePath(root, 'stacks/./compose.yml', 'Compose path')).toBe(`${root}/stacks/compose.yml`);
|
|
});
|
|
|
|
test('an in-repo .. resolves', () => {
|
|
expect(repoFilePath(root, 'stacks/../compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
|
|
expect(repoFilePath(root, 'app/config/../compose.yml', 'Compose path')).toBe(`${root}/app/compose.yml`);
|
|
expect(repoFilePath(root, 'a/../b/../c/compose.yml', 'Compose path')).toBe(`${root}/c/compose.yml`);
|
|
});
|
|
|
|
test('paths that ESCAPE the repo throw (traversal still blocked)', () => {
|
|
expect(() => repoFilePath(root, '../.env', 'Env file path')).toThrow();
|
|
expect(() => repoFilePath(root, '../shared/compose.yml', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, 'stacks/../../compose.yml', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '../../../../etc/passwd', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '../../costam../compose.yml', 'Compose path')).toThrow();
|
|
});
|
|
|
|
test('a leading / is repo-root-relative, not an error (#1355 regression)', () => {
|
|
// node:path.join used to ignore a leading slash, so /qbittorrent/compose.yaml
|
|
// resolved inside the repo. A later isAbsolute() ban regressed that; restore it.
|
|
expect(repoFilePath(root, '/qbittorrent/compose.yaml', 'Compose path')).toBe(`${root}/qbittorrent/compose.yaml`);
|
|
expect(repoFilePath(root, '/compose.yml', 'Compose path')).toBe(`${root}/compose.yml`);
|
|
expect(repoFilePath(root, '//sub/compose.yml', 'Compose path')).toBe(`${root}/sub/compose.yml`);
|
|
});
|
|
|
|
test('a leading / does NOT let a path escape the repo', () => {
|
|
// After stripping the leading slash the containment check still runs, so a
|
|
// traversal like /../x resolves to ../x and is refused.
|
|
expect(() => repoFilePath(root, '/../etc/passwd', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '/../../compose.yml', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '///../../root/.ssh/id_rsa', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '/./../../etc/passwd', 'Compose path')).toThrow();
|
|
expect(() => repoFilePath(root, '/foo/../../../etc/passwd', 'Compose path')).toThrow();
|
|
});
|
|
|
|
test('a sibling-dir prefix attack is blocked (root + sep, not bare prefix)', () => {
|
|
// Without the trailing separator, resolve to "/app/data/git-repos/env/stack-evil"
|
|
// would falsely pass a bare startsWith(root). The `+ sep` closes that hole.
|
|
expect(() => repoFilePath(root, '../stack-evil/x.yml', 'Compose path')).toThrow();
|
|
});
|
|
|
|
test('dotty filenames that are NOT .. / . segments still resolve', () => {
|
|
expect(repoFilePath(root, '..foo/compose.yml', 'Compose path')).toBe(`${root}/..foo/compose.yml`);
|
|
expect(repoFilePath(root, 'foo..bar/compose.yml', 'Compose path')).toBe(`${root}/foo..bar/compose.yml`);
|
|
});
|
|
});
|