diff --git a/projects/postcss/Dockerfile b/projects/postcss/Dockerfile new file mode 100644 index 000000000000..ffcc6433de55 --- /dev/null +++ b/projects/postcss/Dockerfile @@ -0,0 +1,32 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +FROM gcr.io/oss-fuzz-base/base-builder-javascript + +COPY build.sh $SRC/ + +# PostCSS's default branch is `main`. Pin it explicitly so the integration +# does not silently break if the default ever changes. The fuzzing harness +# (`test/fuzzing/fuzz_parse.js`) and its dictionary live in this repo, so +# they are picked up by the clone without a separate COPY step. +RUN git clone --depth 1 -b main https://github.com/postcss/postcss + +# postcss-parser-tests is the upstream-maintained collection of CSS test +# cases. We use its `cases/` directory as the seed corpus so the fuzzer +# starts mutating from real, parser-shaped inputs. +RUN git clone --depth 1 -b main https://github.com/postcss/postcss-parser-tests + +WORKDIR $SRC/postcss diff --git a/projects/postcss/build.sh b/projects/postcss/build.sh new file mode 100755 index 000000000000..4de4c702aa71 --- /dev/null +++ b/projects/postcss/build.sh @@ -0,0 +1,38 @@ +#!/bin/bash -eu +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +# Install runtime dependencies only. PostCSS's devDependencies pull in tools +# that have peer-dep conflicts and are unrelated to the library's runtime +# behavior, so we skip them. +npm install --omit=dev --ignore-scripts --legacy-peer-deps +npm install --save-dev --legacy-peer-deps @jazzer.js/core + +# Build a seed corpus from the upstream postcss-parser-tests CSS cases so +# the fuzzer starts mutating from realistic, parser-shaped inputs rather +# than from empty bytes. +mkdir -p "$WORK/seed_corpus" +cp "$SRC"/postcss-parser-tests/cases/*.css "$WORK/seed_corpus/" +(cd "$WORK/seed_corpus" && zip -q -r "$OUT/fuzz_parse_seed_corpus.zip" .) + +# Ship the CSS dictionary alongside the fuzzer so libFuzzer can splice in +# common CSS tokens during mutation. The dictionary lives in the upstream +# postcss repo under test/fuzzing/, so it is already present in the clone. +cp "$SRC/postcss/test/fuzzing/fuzz_parse.dict" "$OUT/fuzz_parse.dict" + +# Build Fuzzers. The harness lives upstream at test/fuzzing/fuzz_parse.js +# and is supplied by the postcss clone above. +compile_javascript_fuzzer postcss test/fuzzing/fuzz_parse.js -i postcss --sync diff --git a/projects/postcss/fuzz_parse.dict b/projects/postcss/fuzz_parse.dict new file mode 100644 index 000000000000..210fd1b09163 --- /dev/null +++ b/projects/postcss/fuzz_parse.dict @@ -0,0 +1,101 @@ +# Dictionary of common CSS tokens for libFuzzer. +# Reference: https://www.w3.org/TR/css-syntax-3/ + +# At-rules +"@charset " +"@import " +"@media " +"@supports " +"@font-face " +"@keyframes " +"@page " +"@namespace " +"@document " +"@layer " +"@container " +"@property " +"@scope " +"@counter-style " +"@font-feature-values " + +# Structural punctuation +"{" +"}" +";" +":" +"," +"(" +")" +"[" +"]" +"/*" +"*/" +"!important" +"--" + +# Selectors +"*" +">" +"+" +"~" +"::" +"&" + +# Common pseudo-classes / pseudo-elements +":hover" +":focus" +":active" +":root" +":not(" +":is(" +":where(" +":has(" +"::before" +"::after" + +# Common properties +"color:" +"background:" +"background-color:" +"width:" +"height:" +"margin:" +"padding:" +"border:" +"display:" +"position:" +"font-size:" +"font-family:" +"transform:" +"transition:" +"animation:" +"grid-template-columns:" +"flex:" + +# Values / units +"px" +"em" +"rem" +"%" +"vh" +"vw" +"deg" +"rgb(" +"rgba(" +"hsl(" +"hsla(" +"calc(" +"var(" +"url(" +"linear-gradient(" +"none" +"auto" +"inherit" +"initial" +"unset" +"revert" + +# Strings / escapes +"\"" +"'" +"\\" diff --git a/projects/postcss/fuzz_parse.js b/projects/postcss/fuzz_parse.js new file mode 100644 index 000000000000..47333fcd70e9 --- /dev/null +++ b/projects/postcss/fuzz_parse.js @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// + +const { FuzzedDataProvider } = require('@jazzer.js/core'); +const postcss = require('./lib/postcss'); + +module.exports.fuzz = function (data) { + const provider = new FuzzedDataProvider(data); + + // The CSS input itself is randomized: every byte the fuzzer produces (or + // mutates from the seed corpus) flows directly into `cssString` via + // consumeRemainingAsString(). The option flags below are read from the + // *back* of the buffer (jazzer.js consumes integrals/booleans from the + // tail), so seed CSS files from postcss-parser-tests are fed into the + // parser nearly verbatim, with only their last few bytes nibbled off as + // option control. + const useMap = provider.consumeBoolean(); + const useFrom = provider.consumeBoolean(); + const useProcessor = provider.consumeBoolean(); + const splitMode = provider.consumeIntegralInRange(0, 2); + const cssString = provider.consumeRemainingAsString(); + + const parseOptions = {}; + if (useFrom) parseOptions.from = 'fuzz.css'; + if (useMap) parseOptions.map = { inline: false, annotation: false }; + + let root; + try { + root = postcss.parse(cssString, parseOptions); + } catch (e) { + if (e instanceof postcss.CssSyntaxError) return; + throw e; + } + + // Walk the AST and exercise common node accessors. This also stresses + // raws/source bookkeeping for any node returned by the parser. + try { + root.walk(node => { + void node.type; + void node.toString(); + if (typeof node.error === 'function') { + // Generating an error message touches input/source-map machinery. + node.error('fuzz').message; + } + }); + } catch (e) { + if (!isExpected(e, postcss)) throw e; + } + + // Round-trip via stringify and re-parse. Output should itself be parseable. + let serialized; + try { + serialized = root.toString(); + } catch (e) { + if (!isExpected(e, postcss)) throw e; + return; + } + + try { + postcss.parse(serialized); + } catch (e) { + if (!(e instanceof postcss.CssSyntaxError)) throw e; + } + + // Exercise the JSON serialization round-trip. + try { + const json = root.toJSON(); + postcss.fromJSON(json); + } catch (e) { + if (!isExpected(e, postcss)) throw e; + } + + // Exercise the main public entry point: postcss().process(). This drives + // the LazyResult / NoWorkResult pipeline that real plugin chains use. + if (useProcessor) { + try { + const result = postcss().process(cssString, parseOptions); + void result.css; + void result.warnings(); + } catch (e) { + if (!isExpected(e, postcss)) throw e; + } + } + + // Exercise the list helpers, which have their own quoting/escape logic. + try { + if (splitMode === 0) { + postcss.list.comma(cssString); + } else if (splitMode === 1) { + postcss.list.space(cssString); + } else { + postcss.list.split(cssString, [',', ' '], false); + } + } catch (e) { + if (!isExpected(e, postcss)) throw e; + } +}; + +function isExpected(error, postcss) { + if (error instanceof postcss.CssSyntaxError) return true; + if (!error || typeof error.message !== 'string') return false; + // Some legitimate inputs reach known-shaped TypeErrors during stringify or + // walk because the CSS allows constructs whose textual form is ambiguous. + // Suppress only those well-defined cases so real bugs still surface. + const benign = [ + 'Unknown node type', + 'Unknown word', + ]; + return benign.some(msg => error.message.indexOf(msg) !== -1); +} diff --git a/projects/postcss/project.yaml b/projects/postcss/project.yaml new file mode 100644 index 000000000000..512830f271dd --- /dev/null +++ b/projects/postcss/project.yaml @@ -0,0 +1,8 @@ +homepage: https://postcss.org/ +language: javascript +primary_contact: "andrey@sitnik.es" +main_repo: https://github.com/postcss/postcss +fuzzing_engines: +- libfuzzer +sanitizers: +- none