Files
Tom Moor 76a3ba4e83 fix: Normalize IP addresses to avoid validation errors (#12500)
* fix: Normalize IP addresses to avoid validation errors on audit columns

Koa's `ctx.request.ip` can yield values that fail Sequelize's `isIP`
validation (X-Forwarded-For chains, IPv6 zone identifiers, "unknown"
from misconfigured proxies). This drops the IP metadata silently
instead of raising a 500 on Event/User writes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: Cover IP normalization on User setters

Reviewer feedback. Also switches the column-options `set` to TypeScript
get/set accessors — the original approach was shadowed by the class
field declaration and never actually fired, which the new tests would
have caught.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:52:05 -04:00

196 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CreateOptions,
InferAttributes,
InferCreationAttributes,
SaveOptions,
WhereOptions,
} from "sequelize";
import {
ForeignKey,
AfterSave,
BeforeCreate,
BelongsTo,
Column,
IsIP,
IsUUID,
Table,
DataType,
Length,
} from "sequelize-typescript";
import { globalEventQueue } from "../queues";
import type { APIContext } from "../types";
import { AuthenticationType } from "../types";
import { normalizeIp } from "../utils/ip";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import type { Context } from "koa";
@Table({ tableName: "events", modelName: "event", updatedAt: false })
@Fix
class Event extends IdModel<
InferAttributes<Event>,
Partial<InferCreationAttributes<Event>>
> {
@IsUUID(4)
@Column(DataType.UUID)
modelId: string | null;
/** The name of the event. */
@Length({
max: 255,
msg: "name must be 255 characters or less",
})
@Column(DataType.STRING)
name: string;
/** The originating IP address of the event. */
@IsIP
@Column
ip: string | null;
/** The type of authentication used to create the event. */
@Column(DataType.ENUM(...Object.values(AuthenticationType)))
authType: AuthenticationType | null;
/**
* Metadata associated with the event, previously used for storing some changed attributes.
* Note that the `data` column will be visible to the client and API requests.
*/
@Column(DataType.JSONB)
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any> | null;
/**
* The changes made to the model gradually moving to this column away from `data` which can be
* used for arbitrary data associated with the event.
*/
@Column(DataType.JSONB)
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
changes: Record<string, any> | null;
// hooks
@BeforeCreate
static cleanupIp(model: Event) {
model.ip = normalizeIp(model.ip);
}
@AfterSave
static async enqueue(
model: Event,
options: SaveOptions<InferAttributes<Event>>
) {
if (options.transaction) {
// 'findOrCreate' creates a new transaction always, and the transaction from the middleware is set as its parent.
// We want to use the parent transaction, otherwise the 'afterCommit' hook will never fire in this case.
// See: https://github.com/sequelize/sequelize/issues/17452
(options.transaction.parent || options.transaction).afterCommit(
() => void globalEventQueue().add(model)
);
return;
}
void globalEventQueue().add(model);
}
// associations
@BelongsTo(() => User, "userId")
user: User | null;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string | null;
@BelongsTo(() => Document, "documentId")
document: Document | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string | null;
@BelongsTo(() => User, "actorId")
actor: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
actorId: string | null;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string | null;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
/*
* Schedule can be used to send events into the event system without recording
* them in the database or audit trail consider using a task instead.
*/
static schedule(event: Partial<Event>) {
const now = new Date();
return globalEventQueue().add(
this.build({
createdAt: now,
...event,
})
);
}
/**
* Find the latest event matching the where clause
*
* @param where The options to match against
* @returns A promise resolving to the latest event or null
*/
static findLatest(where: WhereOptions) {
return this.findOne({
where,
order: [["createdAt", "DESC"]],
});
}
/**
* Create and persist new event from request context
*
* @param ctx The request context to use
* @param attributes The event attributes
* @returns A promise resolving to the new event
*/
static createFromContext(
ctx: Context | APIContext,
attributes: Omit<Partial<Event>, "ip" | "teamId" | "actorId"> = {},
defaultAttributes: Pick<Partial<Event>, "ip" | "teamId" | "actorId"> = {},
options?: CreateOptions<InferAttributes<Event>>
) {
const user = ctx.state.auth?.user;
const authType = ctx.state.auth?.type;
return this.create(
{
...attributes,
actorId: user?.id || defaultAttributes.actorId,
teamId: user?.teamId || defaultAttributes.teamId,
ip: ctx.request?.ip || defaultAttributes.ip,
authType,
},
{
transaction: ctx.state.transaction,
...options,
}
);
}
}
export default Event;