fix: close account authorization gaps (#755)

This commit is contained in:
Thomas Durieux
2026-07-22 04:29:04 +02:00
committed by GitHub
parent aab6ccf7fc
commit beef8387ae
7 changed files with 176 additions and 28 deletions
+22
View File
@@ -0,0 +1,22 @@
import * as express from "express";
export function isDisabledAccount(
status: string | undefined
): status is "banned" | "removed" {
return status === "banned" || status === "removed";
}
export function getLoginToken(
req: Pick<express.Request, "headers" | "body">
): string | null {
const authorization = req.headers.authorization;
if (typeof authorization === "string") {
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (match) return match[1].trim();
}
if (req.body && typeof req.body.token === "string") {
return req.body.token.trim() || null;
}
return null;
}
+36 -20
View File
@@ -13,6 +13,7 @@ import AnonymousError from "../../core/AnonymousError";
import AnonymizedPullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model"; import AnonymizedPullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
import { hashToken } from "./token-auth"; import { hashToken } from "./token-auth";
import { createLogger, serializeError } from "../../core/logger"; import { createLogger, serializeError } from "../../core/logger";
import { getLoginToken, isDisabledAccount } from "./auth-utils";
const logger = createLogger("auth"); const logger = createLogger("auth");
@@ -38,6 +39,15 @@ const verify = async (
const now = new Date(); const now = new Date();
user = await UserModel.findOne({ "externalIDs.github": profile.id }); user = await UserModel.findOne({ "externalIDs.github": profile.id });
if (user) { if (user) {
if (isDisabledAccount(user.status)) {
done(
new AnonymousError(
user.status === "banned" ? "user_banned" : "not_connected",
{ httpStatus: user.status === "banned" ? 403 : 401 }
)
);
return;
}
await UserModel.updateOne( await UserModel.updateOne(
{ _id: user._id }, { _id: user._id },
{ {
@@ -59,6 +69,15 @@ const verify = async (
// the isAdmin flag. // the isAdmin flag.
user = await UserModel.findOne({ username: profile.username }); user = await UserModel.findOne({ username: profile.username });
if (user) { if (user) {
if (isDisabledAccount(user.status)) {
done(
new AnonymousError(
user.status === "banned" ? "user_banned" : "not_connected",
{ httpStatus: user.status === "banned" ? 403 : 401 }
)
);
return;
}
await UserModel.updateOne( await UserModel.updateOne(
{ _id: user._id }, { _id: user._id },
{ {
@@ -92,11 +111,14 @@ const verify = async (
await user.save(); await user.save();
} }
} }
if (user!.status === "banned") { if (isDisabledAccount(user!.status)) {
done( done(
new AnonymousError("user_banned", { new AnonymousError(
httpStatus: 403, user!.status === "banned" ? "user_banned" : "not_connected",
}) {
httpStatus: user!.status === "banned" ? 403 : 401,
}
)
); );
return; return;
} }
@@ -181,22 +203,13 @@ router.get(
} }
); );
// Dev-friendly login: accept an admin API token and establish a session // Accept an API token and establish a session cookie so the web UI is
// cookie so the web UI is reachable without going through GitHub OAuth. // reachable without going through GitHub OAuth. Keep credentials out of URLs,
// Token may come from `Authorization: Bearer …`, `?token=…`, or JSON body. // which are routinely retained in access logs and browser history.
router.all( router.post(
"/login-token", "/login-token",
async function (req: express.Request, res: express.Response) { async function (req: express.Request, res: express.Response) {
const fromHeader = (() => { const token = getLoginToken(req);
const h = req.headers["authorization"];
if (typeof h !== "string") return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1].trim() : null;
})();
const token =
fromHeader ||
(typeof req.query.token === "string" ? req.query.token : null) ||
(req.body && typeof req.body.token === "string" ? req.body.token : null);
if (!token) { if (!token) {
return res.status(400).json({ error: "missing_token" }); return res.status(400).json({ error: "missing_token" });
} }
@@ -205,7 +218,11 @@ router.all(
"apiTokens.tokenHash": hashToken(token), "apiTokens.tokenHash": hashToken(token),
}); });
if (!model) return res.status(401).json({ error: "invalid_token" }); if (!model) return res.status(401).json({ error: "invalid_token" });
if (model.status === "banned") return res.status(403).json({ error: "user_banned" }); if (isDisabledAccount(model.status)) {
return res.status(model.status === "banned" ? 403 : 401).json({
error: model.status === "banned" ? "user_banned" : "not_connected",
});
}
const synthUser = { const synthUser = {
username: model.username, username: model.username,
accessToken: model.accessTokens?.github, accessToken: model.accessTokens?.github,
@@ -221,7 +238,6 @@ router.all(
{ _id: model._id, "apiTokens.tokenHash": hashToken(token) }, { _id: model._id, "apiTokens.tokenHash": hashToken(token) },
{ $set: { "apiTokens.$.lastUsedAt": new Date() } } { $set: { "apiTokens.$.lastUsedAt": new Date() } }
).catch(() => undefined); ).catch(() => undefined);
if (req.method === "GET") return res.redirect("/");
return res.json({ ok: true, username: model.username }); return res.json({ ok: true, username: model.username });
}); });
} catch (err) { } catch (err) {
+15 -6
View File
@@ -7,6 +7,7 @@ import Repository from "../../core/Repository";
import { HTTPError } from "got"; import { HTTPError } from "got";
import { RepositoryStatus } from "../../core/types"; import { RepositoryStatus } from "../../core/types";
import { createLogger, serializeError } from "../../core/logger"; import { createLogger, serializeError } from "../../core/logger";
import { isDisabledAccount } from "./auth-utils";
const logger = createLogger("route"); const logger = createLogger("route");
@@ -101,8 +102,13 @@ export function isOwnerOrAdmin(authorizedUsers: string[], user: User) {
} }
export function isCoauthor(repo: Repository, user: User): boolean { export function isCoauthor(repo: Repository, user: User): boolean {
if (!user.username) return false; const githubId = user.model.externalIDs?.github;
return (repo.model.coauthors || []).some((c) => c.username === user.username); return (repo.model.coauthors || []).some((coauthor) => {
if (coauthor.githubId) {
return Boolean(githubId && coauthor.githubId === githubId);
}
return Boolean(user.username && coauthor.username === user.username);
});
} }
export function isOwnerCoauthorOrAdmin(repo: Repository, user: User) { export function isOwnerCoauthorOrAdmin(repo: Repository, user: User) {
@@ -270,15 +276,18 @@ export async function getUser(req: express.Request) {
if (!model) { if (!model) {
notConnected(); notConnected();
} }
if (model.status === "banned") { if (isDisabledAccount(model.status)) {
req.logout((error) => { req.logout((error) => {
if (error) { if (error) {
logger.error("logout failed", serializeError(error)); logger.error("logout failed", serializeError(error));
} }
}); });
throw new AnonymousError("user_banned", { throw new AnonymousError(
httpStatus: 403, model.status === "banned" ? "user_banned" : "not_connected",
}); {
httpStatus: model.status === "banned" ? 403 : 401,
}
);
} }
return new User(model); return new User(model);
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import * as express from "express";
import * as crypto from "crypto"; import * as crypto from "crypto";
import UserModel from "../../core/model/users/users.model"; import UserModel from "../../core/model/users/users.model";
import { createLogger, serializeError } from "../../core/logger"; import { createLogger, serializeError } from "../../core/logger";
import { isDisabledAccount } from "./auth-utils";
const logger = createLogger("token-auth"); const logger = createLogger("token-auth");
@@ -29,7 +30,7 @@ export async function bearerTokenAuth(
try { try {
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash }); const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
if (!model) return next(); if (!model) return next();
if (model.status === "banned") return next(); if (isDisabledAccount(model.status)) return next();
// Mirror the shape produced by passport's verify() in connection.ts // Mirror the shape produced by passport's verify() in connection.ts
// so existing getUser()/route code works unchanged. // so existing getUser()/route code works unchanged.
+19
View File
@@ -5,6 +5,8 @@ import { ensureAuthenticated } from "./connection";
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils"; import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
import UserModel from "../../core/model/users/users.model"; import UserModel from "../../core/model/users/users.model";
import AnonymizedRepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model"; import AnonymizedRepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
import AnonymizedPullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
import AnonymizedGistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
import User from "../../core/User"; import User from "../../core/User";
import FileModel from "../../core/model/files/files.model"; import FileModel from "../../core/model/files/files.model";
import { isConnected } from "../database"; import { isConnected } from "../database";
@@ -170,6 +172,23 @@ router.delete("/", async (req: express.Request, res: express.Response) => {
if (gist.status !== "removed") await gist.remove(); if (gist.status !== "removed") await gist.remove();
} }
// Removal keeps anonymized records as tombstones, so explicitly erase the
// duplicated OAuth credentials stored on each source record.
await Promise.all([
AnonymizedRepositoryModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "" } }
).exec(),
AnonymizedPullRequestModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "" } }
).exec(),
AnonymizedGistModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "" } }
).exec(),
]);
// Revoke the OAuth grant so the application no longer appears in the // Revoke the OAuth grant so the application no longer appears in the
// user's GitHub authorized applications. Best-effort: the account is // user's GitHub authorized applications. Best-effort: the account is
// scrubbed even if GitHub rejects the revocation. // scrubbed even if GitHub rejects the revocation.
+56
View File
@@ -0,0 +1,56 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const {
getLoginToken,
isDisabledAccount,
} = require("../src/server/routes/auth-utils");
describe("auth-utils.getLoginToken", function () {
it("accepts a bearer token", function () {
expect(
getLoginToken({ headers: { authorization: "Bearer secret" }, body: {} })
).to.equal("secret");
});
it("accepts a token in the request body", function () {
expect(getLoginToken({ headers: {}, body: { token: "secret" } })).to.equal(
"secret"
);
});
it("does not inspect query-string credentials", function () {
expect(
getLoginToken({ headers: {}, body: {}, query: { token: "leaked" } })
).to.equal(null);
});
it("rejects empty body tokens", function () {
expect(getLoginToken({ headers: {}, body: { token: " " } })).to.equal(
null
);
});
});
describe("login-token route", function () {
it("only accepts POST requests", function () {
const { router } = require("../src/server/routes/connection");
const layer = router.stack.find(
(candidate) => candidate.route?.path === "/login-token"
);
expect(layer).to.not.equal(undefined);
expect(layer.route.methods).to.deep.equal({ post: true });
});
});
describe("auth-utils.isDisabledAccount", function () {
it("rejects banned and removed accounts", function () {
expect(isDisabledAccount("banned")).to.equal(true);
expect(isDisabledAccount("removed")).to.equal(true);
});
it("allows active and legacy accounts", function () {
expect(isDisabledAccount("active")).to.equal(false);
expect(isDisabledAccount(undefined)).to.equal(false);
});
});
+26 -1
View File
@@ -19,7 +19,7 @@ const Repository = require("../src/core/Repository").default;
* model objects without a live DB. * model objects without a live DB.
*/ */
function makeUser({ id, username, isAdmin = false } = {}) { function makeUser({ id, username, githubId, isAdmin = false } = {}) {
const _id = id || new mongoose.Types.ObjectId(); const _id = id || new mongoose.Types.ObjectId();
return new User( return new User(
new UserModel({ new UserModel({
@@ -28,6 +28,7 @@ function makeUser({ id, username, isAdmin = false } = {}) {
username, username,
isAdmin, isAdmin,
accessTokens: { github: "tok" }, accessTokens: { github: "tok" },
externalIDs: githubId ? { github: githubId } : {},
}) })
); );
} }
@@ -105,6 +106,30 @@ describe("route-utils.isCoauthor", function () {
expect(isCoauthor(repo, user)).to.equal(false); expect(isCoauthor(repo, user)).to.equal(false);
}); });
it("uses the immutable GitHub id when a coauthor id is present", function () {
const user = makeUser({ username: "renamed", githubId: "123" });
const repo = makeRepo({
coauthors: [{ username: "old-name", githubId: "123" }],
});
expect(isCoauthor(repo, user)).to.equal(true);
});
it("does not grant access from a recycled username", function () {
const user = makeUser({ username: "alice", githubId: "456" });
const repo = makeRepo({
coauthors: [{ username: "alice", githubId: "123" }],
});
expect(isCoauthor(repo, user)).to.equal(false);
});
it("matches by GitHub id even when the user has no username", function () {
const user = makeUser({ username: undefined, githubId: "123" });
const repo = makeRepo({
coauthors: [{ username: "old-name", githubId: "123" }],
});
expect(isCoauthor(repo, user)).to.equal(true);
});
it("matches case-sensitively (alice !== Alice)", function () { it("matches case-sensitively (alice !== Alice)", function () {
const user = makeUser({ username: "alice" }); const user = makeUser({ username: "alice" });
const repo = makeRepo({ coauthors: [{ username: "Alice" }] }); const repo = makeRepo({ coauthors: [{ username: "Alice" }] });