openvibers/openvibe.codes Commits

Codes: the API agent cannot read through symlinks, /auth/me is not rate-limited, every sign-out method is same-origin (#35)

OP OpenVibers committed 10 Oct 2026 · dbdee25d4e · 5 files, +74 −4

Changes

ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex 9e3173a..dfcaf85 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -34,15 +34,38 @@ const LEVELS = { read: 1, edit: 2, full: 3 };
const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);
++/**
++ * The real path of abs, with any part that does not exist yet kept as written; null when a component is
++ * not a directory. Every existing component is realpath'd, so a symlink cannot hide where the path goes.
++ */
++function realPath(abs) {
++ let p = path.resolve(abs);
++ const tail = [];
++ for (;;) {
++ try { return path.join(fs.realpathSync(p), ...tail); }
++ catch (err) {
++ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') return null;
++ const parent = path.dirname(p);
++ if (parent === p) return null;
++ tail.unshift(path.basename(p));
++ p = parent;
++ }
++ }
++}
++
/** A path inside the working directory, or an Error that says why not. */
function inside(cwd, p, { write = false } = {}) {
const root = fs.realpathSync(cwd);
const abs = path.resolve(root, String(p || '.'));
if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return new Error(`${p} is outside the working directory`);
-- const rel = path.relative(root, abs);
++ // Resolve symlinks before trusting the textual check: `link/.env` or `link` → a path outside the
++ // working directory would otherwise read and write through the link.
++ const real = realPath(abs);
++ if (real === null || (real !== root && !real.startsWith(`${root}${path.sep}`))) return new Error(`${p} is outside the working directory`);
++ const rel = path.relative(root, real);
if (/(^|[\\/])\.env(\.|$)/.test(rel)) return new Error('.env files are not read or written');
if (write && /(^|[\\/])\.git([\\/]|$)/.test(rel)) return new Error('.git is not written');
-- return abs;
++ return real;
}
function runTool(name, args, cwd) {
@@@ -71,7 +94,8 @@ function runTool(name, args, cwd) {
const hits = [];
const walk = (p) => {
if (hits.length >= 200) return;
-- const st = fs.statSync(p);
++ const st = fs.lstatSync(p);
++ if (st.isSymbolicLink()) return; // a link can point anywhere: never followed
if (st.isDirectory()) { for (const n of fs.readdirSync(p)) if (!SKIP_DIRS.has(n) && !/^\.env/.test(n)) walk(path.join(p, n)); return; }
if (st.size > 1_000_000) return;
const text = fs.readFileSync(p, 'utf8');
ddiff --git a/server/app.js b/server/app.js
iindex 84ec8ab..fb409aa 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -111,7 +111,9 @@ async function createApp(opts = {}) {
app.use(sso.middleware());
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
-- app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ // The limit is on the sign-in routes only: the shared navbar asks /auth/me on every page view, so
++ // counting it here would 429 (and render the visitor signed out) after 60 views in 15 minutes.
++ app.use(['/auth/login', '/auth/callback', '/auth/logout'], rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'codes', service: 'codes', host: 'openvibe.codes', name: 'OpenVibe.Codes', profile: 'ugc' })); }
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex d59023d..61f97b1 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -227,7 +227,11 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
+ `<p>Sign out of OpenVibe.Codes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
});
r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ // The GET (confirm page) and POST (403) handlers above only cover those two methods; this runs for
++ // every other one too, so it repeats the same-origin check rather than signing out on a cross-site
++ // DELETE (or any other verb).
r.all('/logout', async (req, res) => {
++ if (!sameOriginRequest(req)) return res.status(403).type('text/plain').send('Sign-out must come from this site.');
const rt = req.cookies && req.cookies[REFRESH_COOKIE];
if (rt) {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex bccd0bf..36be9b0 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -46,6 +46,12 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
fs.writeFileSync(path.join(work, 'src', 'a.js'), 'const name = 1;\nmodule.exports = name;\n');
fs.writeFileSync(path.join(work, '.env'), 'SECRET=1\n');
fs.writeFileSync(path.join(dir, 'outside.txt'), 'outside');
++ fs.mkdirSync(path.join(dir, 'outside-dir'), { recursive: true });
++ fs.writeFileSync(path.join(dir, 'outside-dir', 'secret.txt'), 'OUTSIDE-SECRET-KEY\n');
++ // Symlinks inside the working directory: they resolve textually inside it, but point out (or at .env).
++ fs.symlinkSync(path.join(dir, 'outside.txt'), path.join(work, 'outside-link'));
++ fs.symlinkSync(path.join(work, '.env'), path.join(work, 'env-link'));
++ fs.symlinkSync(path.join(dir, 'outside-dir'), path.join(work, 'dir-link'));
const env = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home'), OPENVIBE_CODES_BASE_URL: api.url, OPENVIBE_CODES_MODEL: 'tiny', OPENVIBE_CODES_API_KEY: 'k-123' };
const h = createHarness({ env, store: createStore({ env }) });
@@@ -90,6 +96,24 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.ok(!fs.existsSync(path.join(work, '.git')));
});
++ await check('a symlink inside the working directory cannot carry a read or write past it', async () => {
++ api.set([{ calls: [
++ ['read_file', { path: 'outside-link' }],
++ ['read_file', { path: 'env-link' }],
++ ['read_file', { path: 'dir-link/secret.txt' }],
++ ['write_file', { path: 'dir-link/planted.txt', content: 'x' }],
++ ['grep', { pattern: 'OUTSIDE-SECRET-KEY' }],
++ ] }, { content: 'ok' }]);
++ const events = await collect(h.run({ prompt: 'escape', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
++ const res = toolResults(events);
++ assert.strictEqual(res.length, 5, JSON.stringify(res.map((c) => c.content)));
++ assert.ok(res.slice(0, 4).every((c) => c.is_error), JSON.stringify(res.map((c) => c.content)));
++ assert.ok(res.slice(0, 4).every((c) => /outside the working directory|\.env files are not read/.test(c.content)), JSON.stringify(res.map((c) => c.content)));
++ assert.strictEqual(res[4].content, 'no matches', 'grep did not follow the linked directory');
++ assert.ok(!res.some((c) => /SECRET=1|OUTSIDE-SECRET-KEY/.test(c.content)), 'nothing behind a link came back');
++ assert.ok(!fs.existsSync(path.join(dir, 'outside-dir', 'planted.txt')), 'no write through the link');
++ });
++
await check('run_command exists only at the full level, and runs in the working directory', async () => {
api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);
const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex e7b435c..096e3cc 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -58,12 +58,28 @@ const { signJwt } = require('./helpers/mocks');
assert.match(cross.text, /method="post"/);
const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
assert.strictEqual(crossPost.status, 403);
++ // The GET/POST handlers only cover those verbs; any other (DELETE here) must be refused too.
++ const crossDelete = await s.get('/auth/logout', { as: staff, method: 'DELETE', headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(crossDelete.status, 403, 'a cross-site DELETE is refused');
++ assert.ok(!/codes_at=;|codes_at=deleted/i.test(String(crossDelete.headers.get('set-cookie') || '')), 'the session cookie is kept');
const same = await s.get('/auth/logout?next=/improve', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
assert.strictEqual(same.status, 303);
assert.strictEqual(same.headers.get('location'), '/improve');
assert.match(String(same.headers.get('set-cookie') || ''), /codes_at=;/);
});
++ await check('/auth/me is not counted by the sign-in limiter; the sign-in routes still are', async () => {
++ // The shared navbar asks /auth/me on every page view; it must not eat (or be eaten by) the
++ // 60-per-15-minutes sign-in budget (server/app.js).
++ for (let i = 0; i < 65; i++) {
++ const me = await s.get('/auth/me');
++ assert.strictEqual(me.status, 200, `/auth/me request ${i + 1} answered ${me.status}`);
++ }
++ let last = 0;
++ for (let i = 0; i < 61; i++) last = (await s.get('/auth/login')).status;
++ assert.strictEqual(last, 429, 'the sign-in route is still limited');
++ });
++
await s.close();
done();
})();