allowedMimeTypes : jokers de préfixe
Some checks failed
Publier l'image Docker / test (push) Successful in 18s
Publier l'image Docker / publish (push) Failing after 11s

isAllowedMedia gère désormais "video/*", "*/*" et "*" en plus des types
exacts (comparaison insensible à la casse). parseConfig valide la forme des
entrées et lève ConfigError sur un joker malformé ("video/**", "vi*deo/mp4",
"video/") au lieu de laisser passer une liste qui ne matche rien.

Schéma + doc mis à jour. 62 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-10 09:18:52 +02:00
parent 5bff28fefe
commit 0f5bdb7080
6 changed files with 66 additions and 20 deletions

View file

@ -166,7 +166,7 @@ Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma
[`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant. [`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant.
| Clé JSON | Défaut | Rôle | | Clé JSON | Défaut | Rôle |
| ---------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | | ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `port` | `3000` | Port d'écoute | | `port` | `3000` | Port d'écoute |
| `host` | `0.0.0.0` | Interface d'écoute | | `host` | `0.0.0.0` | Interface d'écoute |
| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | | `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) |
@ -174,7 +174,7 @@ Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma
| `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) | | `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) |
| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) | | `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) |
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | | `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
| `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; absent => liste par défaut | | `allowedMimeTypes` | _(liste média)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut |
| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) | | `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) |
| `session.maxAgeHours` | `12` | Durée de vie absolue de la session | | `session.maxAgeHours` | `12` | Durée de vie absolue de la session |
| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | | `oidc.issuer` | — | **Obligatoire.** Issuer OIDC |

View file

@ -44,10 +44,14 @@
"default": "info" "default": "info"
}, },
"allowedMimeTypes": { "allowedMimeTypes": {
"description": "Liste blanche de types MIME, ou \"all\". Absent => liste média par défaut.", "description": "\"all\", ou liste de types exacts et/ou jokers de préfixe (\"video/*\", \"*/*\", \"*\"). Absent => liste média par défaut.",
"oneOf": [ "oneOf": [
{ "const": "all" }, { "const": "all" },
{ "type": "array", "items": { "type": "string" }, "minItems": 1 } {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^(\\*|\\*/\\*|[^/*\\s]+/(\\*|[^/*\\s]+))$" }
}
] ]
}, },
"session": { "session": {

View file

@ -150,6 +150,15 @@ function parseMimePolicy(value: unknown): MimePolicy {
if (list.length === 0) { if (list.length === 0) {
throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide'); throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide');
} }
for (const entry of list) {
const valid = entry === '*' || entry === '*/*' || /^[^/*\s]+\/(\*|[^/*\s]+)$/.test(entry);
if (!valid) {
throw new ConfigError(
`allowedMimeTypes : entrée invalide « ${entry} » ` +
'(formes acceptées : "type/sous-type", "type/*", "*/*", "*")',
);
}
}
return list; return list;
} }

View file

@ -38,15 +38,30 @@ const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
'audio/mp4': '.m4a', 'audio/mp4': '.m4a',
}; };
/** Politique de filtrage : une liste blanche explicite, ou `'all'` pour tout accepter. */ /**
* Politique de filtrage : `'all'`, ou une liste d'entrées exactes (ex. `video/mp4`)
* et/ou de jokers de préfixe (ex. `video/` suivi d'une étoile), l'étoile seule
* autorisant tout.
*/
export type MimePolicy = readonly string[] | 'all'; export type MimePolicy = readonly string[] | 'all';
function matchesEntry(mimeType: string, entry: string): boolean {
if (entry === '*' || entry === '*/*') {
return true;
}
if (entry.endsWith('/*')) {
return mimeType.startsWith(entry.slice(0, -1));
}
return entry === mimeType;
}
/** Indique si un type MIME est accepté selon la politique fournie. */ /** Indique si un type MIME est accepté selon la politique fournie. */
export function isAllowedMedia(mimeType: string, policy: MimePolicy): boolean { export function isAllowedMedia(mimeType: string, policy: MimePolicy): boolean {
if (policy === 'all') { if (policy === 'all') {
return true; return true;
} }
return policy.includes(mimeType); const normalized = mimeType.toLowerCase();
return policy.some((entry) => matchesEntry(normalized, entry));
} }
/** /**

View file

@ -128,6 +128,15 @@ describe('parseConfig', () => {
expect(parseConfig({ ...BASE, allowedMimeTypes: 'all' }).allowedMimeTypes).toBe('all'); expect(parseConfig({ ...BASE, allowedMimeTypes: 'all' }).allowedMimeTypes).toBe('all');
expect(() => parseConfig({ ...BASE, allowedMimeTypes: 'image/png' })).toThrow(ConfigError); expect(() => parseConfig({ ...BASE, allowedMimeTypes: 'image/png' })).toThrow(ConfigError);
}); });
it('accepte les jokers valides, rejette les jokers malformés', () => {
expect(
parseConfig({ ...BASE, allowedMimeTypes: ['video/*', 'image/png', '*/*'] }).allowedMimeTypes,
).toEqual(['video/*', 'image/png', '*/*']);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/**'] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['vi*deo/mp4'] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/'] })).toThrow(ConfigError);
});
}); });
describe('loadConfig', () => { describe('loadConfig', () => {

View file

@ -16,6 +16,15 @@ describe('isAllowedMedia', () => {
it('accepte tout avec la politique "all"', () => { it('accepte tout avec la politique "all"', () => {
expect(isAllowedMedia('application/x-msdownload', 'all')).toBe(true); expect(isAllowedMedia('application/x-msdownload', 'all')).toBe(true);
}); });
it('gère les jokers de préfixe', () => {
expect(isAllowedMedia('video/x-matroska', ['video/*'])).toBe(true);
expect(isAllowedMedia('VIDEO/QUICKTIME', ['video/*'])).toBe(true);
expect(isAllowedMedia('image/png', ['video/*'])).toBe(false);
expect(isAllowedMedia('image/png', ['video/*', 'image/png'])).toBe(true);
expect(isAllowedMedia('application/octet-stream', ['*/*'])).toBe(true);
expect(isAllowedMedia('n-importe/quoi', ['*'])).toBe(true);
});
}); });
describe('extensionForMime', () => { describe('extensionForMime', () => {