mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-17 09:09:43 +00:00
PUT-2011
This commit is contained in:
1 parent
a76800d693
commit
50a5f79356
6 files changed
+159
-28
No files matched your search
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user