This commit is contained in:
ProgrammerIn-wonderland committed 2026-10-05 03:09:41 -04:00
1 parent a76800d693
commit 50a5f79356
6 files changed
+159 -28

No files matched your search

+41 -1
View File
@@ -52,6 +52,7 @@ const rawRequest = (
path: string,
headers: Record<string, string> = {},
method = 'GET',
body?: string,
): Promise<RawResponse> =>
new Promise((resolve, reject) => {
const req = http.request(
@@ -70,7 +71,7 @@ const rawRequest = (
},
);
req.on('error', reject);
req.end();
req.end(body);
});
/**
@@ -519,6 +520,14 @@ describe('PuterServer HTTP alarm gate', () => {
});
}) as unknown as RequestHandler,
},
{
method: 'post',
path: '/explode-with-secrets',
options: {},
handler: (() => {
throw new Error('kaboom');
}) as unknown as RequestHandler,
},
{
method: 'get',
path: '/plain',
@@ -607,6 +616,37 @@ describe('PuterServer HTTP alarm gate', () => {
expect(fields).not.toHaveProperty('details');
});
it('keeps request body values, query values and the user row out of the alarm', async () => {
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
const res = await rawRequest(
port,
'/explode-with-secrets?auth_token=query-secret',
{ host: 'puter.localhost', 'content-type': 'application/json' },
'POST',
JSON.stringify({ username: 'u', password: 'body-secret' }),
);
expect(res.status).toBe(500);
const raised = alarm.mock.calls.find((c) =>
String(c[0]).startsWith('http_500:POST:/explode-with-secrets:'),
);
expect(raised).toBeTruthy();
const [, message, fields] = raised! as unknown as [
string,
string,
Record<string, unknown>,
];
const { error: _error, ...rest } = fields;
const serialized = `${message} ${JSON.stringify(rest)}`;
expect(serialized).not.toContain('body-secret');
expect(serialized).not.toContain('query-secret');
expect(fields).not.toHaveProperty('body');
expect(fields.path).toBe('/explode-with-secrets');
expect(fields.bodyKeys).toEqual(['username', 'password']);
expect(fields.queryKeys).toEqual(['auth_token']);
});
it('raises a warning when an upstream account is out of credits', async () => {
const { id, fields, severity } = await raisedFor(
'/credits-exhausted',
+14 -5
View File
@@ -35,7 +35,7 @@ import { createAuthProbe } from './core/http/middleware/authProbe';
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
import { createErrorHandler } from './core/http/middleware/errorHandler';
import type { Actor } from './core/actor';
import { actorUid, type Actor } from './core/actor';
import { isHttpError } from './core/http/HttpError';
import {
adminOnlyGate,
@@ -846,9 +846,14 @@ export class PuterServer {
(req as unknown as { route?: { path?: string } }).route
?.path ?? req.path;
const alarmId = `http_${status}:${req.method}:${routePath}:${signature}`;
// Alarms leave the process, so carry identifiers only:
// bodies and query strings hold passwords, codes and
// tokens, and the actor carries the whole user row.
const urlPath = req.originalUrl.split('?')[0];
const body = req.body as unknown;
this.clients.alarm.create(
alarmId,
`HTTP ${status} on ${req.method} ${req.originalUrl}: ${signature}`,
`HTTP ${status} on ${req.method} ${urlPath}: ${signature}`,
{
// What the thrower attached (an AI chain's
// per-provider attempts, say) rides under one key
@@ -861,10 +866,14 @@ export class PuterServer {
error: err instanceof Error ? err : undefined,
status,
method: req.method,
path: req.originalUrl,
body: req.body,
path: urlPath,
queryKeys: Object.keys(req.query ?? {}),
bodyKeys:
body && typeof body === 'object'
? Object.keys(body)
: [],
route: routePath,
actor: req.actor,
actor: req.actor ? actorUid(req.actor) : undefined,
},
// An unhandled server error is the one thing that
// still pages on-call.
@@ -114,6 +114,26 @@ describe('AppOriginBlocklistService', () => {
true,
);
});
it('treats a fully-qualified trailing dot as the same host', async () => {
const { service } = makeService([
{ domain: 'exact.com', include_subdomains: 0 },
{ domain: 'evil.com.', include_subdomains: 1 },
]);
expect((await service.isHostBlocked('exact.com.')).blocked).toBe(
true,
);
expect(
(await service.isHostBlocked('exact.com.:443')).blocked,
).toBe(true);
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
true,
);
expect(
(await service.isOriginBlocked('https://a.evil.com./x'))
.blocked,
).toBe(true);
});
});
describe('isOriginBlocked', () => {
@@ -143,7 +143,10 @@ export class AppOriginBlocklistService extends PuterService {
}
}
/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */
/**
* Lowercase, trim, drop leading/trailing dots and any port. A trailing dot is
* the fully-qualified spelling of the same host. Returns '' when unusable.
*/
const normalizeHost = (host: string): string => {
let h = (host ?? '').trim().toLowerCase();
if (!h) return '';
@@ -152,7 +155,7 @@ const normalizeHost = (host: string): string => {
// simple split is safe here).
const colon = h.indexOf(':');
if (colon !== -1) h = h.slice(0, colon);
return h;
return h.replace(/\.+$/, '');
};
/** Extract the host from a full URL, falling back to treating input as a host. */
@@ -2184,6 +2184,56 @@ describe('AuthService (integration)', () => {
expect(blocked.actor).toBeUndefined();
expect(blocked.blocked).toBeTruthy();
});
it('rejects an already-issued app access token once its origin is blocked', async () => {
const user = await makeUser();
const host = `late-block-at-${uuidv4()}.example.com`;
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1],
);
const accessToken = server.services.token.sign(
'auth',
{
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
app_uid: appUid,
},
{ expiresIn: '5m' },
);
const ok = await authService.authenticate(accessToken);
expect(ok.actor?.accessToken?.issuer.app?.uid).toBe(appUid);
await blockOrigin(host);
const blocked = await authService.authenticate(accessToken);
expect(blocked.actor).toBeUndefined();
expect(blocked.blocked).toBeTruthy();
});
it('appUidFromOrigin blocks the trailing-dot spelling of a blocked host', async () => {
const host = `blocked-dot-${uuidv4()}.example.com`;
await blockOrigin(host);
await expect(
authService.appUidFromOrigin(`https://${host}./`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_blocked',
});
});
it('appUidFromOrigin resolves the trailing-dot spelling to the same uid', async () => {
const host = `dot-${uuidv4()}.example.com`;
const plain = await authService.appUidFromOrigin(
`https://${host}/`,
);
const dotted = await authService.appUidFromOrigin(
`https://${host}./`,
);
expect(dotted).toBe(plain);
});
});
describe('getUserAppToken', () => {
+29 -20
View File
@@ -1094,13 +1094,17 @@ export class AuthService extends PuterService {
return this.#normalizedOrigin(parsed);
}
/** Scheme + lowercased host + explicit port, with no trailing separator. */
/**
* Scheme + lowercased host without a trailing dot + explicit port, with no
* trailing separator.
*/
#normalizedOrigin(parsed: URL): string {
const port = parsed.port ? `:${parsed.port}` : '';
// `new URL()` lowercases http(s) hosts but leaves opaque ones alone, so
// without this an extension id in two spellings hashes to two app uids
// (and misses the blocklist, which matches on a lowercased host).
return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${port}`;
// `new URL()` lowercases http(s) hosts but leaves opaque ones alone, and
// keeps the fully-qualified trailing dot, so without this one host in
// two spellings hashes to two app uids.
const host = parsed.hostname.toLowerCase().replace(/\.+$/, '');
return `${parsed.protocol}//${host}${port}`;
}
/** Same subdomain on the primary hosting domain; null when not hosted. */
@@ -2123,7 +2127,7 @@ export class AuthService extends PuterService {
}
// Extension schemes aren't "special", so `new URL()` accepts them
// with no authority at all (`chrome-extension:`).
if (!parsed.hostname) {
if (!parsed.hostname.replace(/\.+$/, '')) {
return null;
}
return this.#normalizedOrigin(parsed);
@@ -2189,20 +2193,8 @@ export class AuthService extends PuterService {
const app = await this.stores.app.getByUid(decoded.app_uid);
if (!app) return { invalid: true };
// Reject already-issued app tokens whose app origin is now blocked, so
// a block takes effect immediately rather than waiting for token
// expiry. The app's `index_url` host is the same origin checked at
// token acquisition.
const indexUrl = (app as { index_url?: unknown }).index_url;
if (typeof indexUrl === 'string' && indexUrl) {
const block =
await this.services.appOriginBlocklist.isOriginBlocked(
indexUrl,
);
if (block.blocked) {
return { blocked: { reason: block.reason } };
}
}
const blocked = await this.#appOriginBlock(app);
if (blocked) return { blocked };
let rawRow: SessionRow | null = null;
if (decoded.session_uid) {
@@ -2248,6 +2240,21 @@ export class AuthService extends PuterService {
return { actor };
}
/**
* Block details when `app`'s origin is on the blocklist, so tokens the app
* already holds stop working as soon as it's blocked. The `index_url` host
* is the same origin checked at token acquisition.
*/
async #appOriginBlock(app: {
index_url?: unknown;
}): Promise<{ reason?: string } | null> {
const indexUrl = app.index_url;
if (typeof indexUrl !== 'string' || !indexUrl) return null;
const block =
await this.services.appOriginBlocklist.isOriginBlocked(indexUrl);
return block.blocked ? { reason: block.reason } : null;
}
/**
* Unix seconds before which an app or worker session for `app` belongs to
* an earlier app with the same uid; null when the creation time is
@@ -2294,6 +2301,8 @@ export class AuthService extends PuterService {
if (decoded.app_uid) {
const app = await this.stores.app.getByUid(decoded.app_uid);
if (!app) return { invalid: true };
const blocked = await this.#appOriginBlock(app);
if (blocked) return { blocked };
authorizer = this.#buildAppUnderUserActor(user, app, null);
} else {
authorizer = this.#buildUserActor(user, null);